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
612 lines
23 KiB
Rust
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, "");
|
|
}
|
|
}
|