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
This commit is contained in:
611
crates/lore-tui/src/action/issue_detail.rs
Normal file
611
crates/lore-tui/src/action/issue_detail.rs
Normal file
@@ -0,0 +1,611 @@
|
||||
#![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, "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user