Files
gitlore/crates/lore-tui/src/action/issue_detail.rs
teernisse fb40fdc677 feat(tui): Phase 3 power features — Who, Search, Timeline, Trace, File History screens
Complete TUI Phase 3 implementation with all 5 power feature screens:

- Who screen: 5 modes (expert/workload/reviews/active/overlap) with
  mode tabs, input bar, result rendering, and hint bar
- Search screen: full-text search with result list and scoring display
- Timeline screen: chronological event feed with time-relative display
- Trace screen: file provenance chains with expand/collapse, rename
  tracking, and linked issues/discussions
- File History screen: per-file MR timeline with rename chain display
  and discussion snippets

Also includes:
- Command palette overlay (fuzzy search)
- Bootstrap screen (initial sync flow)
- Action layer split from monolithic action.rs to per-screen modules
- Entity and render cache infrastructure
- Shared who_types module in core crate
- All screens wired into view/mod.rs dispatch
- 597 tests passing, clippy clean (pedantic + nursery), fmt clean
2026-02-18 22:56:38 -05:00

612 lines
23 KiB
Rust

#![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<IssueDetailData> {
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<IssueMetadata> {
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<String>>(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<String> = assignees_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |r| r.get(0))
.context("fetching assignees")?
.collect::<std::result::Result<Vec<_>, _>>()
.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<String> = labels_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |r| r.get(0))
.context("fetching labels")?
.collect::<std::result::Result<Vec<_>, _>>()
.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<Vec<CrossRef>> {
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<i64> = row.get(2)?;
let target_iid: Option<i64> = row.get(3)?;
let target_path: Option<String> = row.get(4)?;
let title: Option<String> = row.get(5)?;
let target_project_id: Option<i64> = 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::<std::result::Result<Vec<_>, _>>()
.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<Vec<DiscussionNode>> {
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::<std::result::Result<Vec<_>, _>>()
.context("reading discussion row")?;
let mut discussions = Vec::new();
for (disc_db_id, discussion_id, resolvable, resolved) in disc_rows {
let notes: Vec<NoteNode> = note_stmt
.query_map(rusqlite::params![disc_db_id], |row| {
Ok(NoteNode {
author: row.get::<_, Option<String>>(0)?.unwrap_or_default(),
body: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
created_at: row.get(2)?,
is_system: row.get(3)?,
is_diff_note: row.get::<_, Option<String>>(4)?.as_deref() == Some("DiffNote"),
diff_file_path: row.get(5)?,
diff_new_line: row.get(6)?,
})
})
.context("fetching notes")?
.collect::<std::result::Result<Vec<_>, _>>()
.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, "");
}
}