Implement a personal work dashboard that shows everything relevant to the
configured GitLab user: open issues assigned to them, MRs they authored,
MRs they are reviewing, and a chronological activity feed.
Design decisions:
- Attention state computed from GitLab interaction data (comments, reviews)
with no local state tracking -- purely derived from existing synced data
- Username resolution: --user flag > config.gitlab.username > actionable error
- Project scoping: --project (fuzzy) | --all | default_project | all
- Section filtering: --issues, --mrs, --activity (combinable, default = all)
- Activity feed controlled by --since (default 30d); work item sections
always show all open items regardless of --since
Architecture (src/cli/commands/me/):
- types.rs: MeDashboard, MeSummary, AttentionState data types
- queries.rs: 4 SQL queries (open_issues, authored_mrs, reviewing_mrs,
activity) using existing issue_assignees, mr_reviewers, notes tables
- render_human.rs: colored terminal output with attention state indicators
- render_robot.rs: {ok, data, meta} JSON envelope with field selection
- mod.rs: orchestration (resolve_username, resolve_project_scope, run_me)
- me_tests.rs: comprehensive unit tests covering all query paths
Config additions:
- New optional gitlab.username field in config.json
- Tests for config with/without username
- Existing test configs updated with username: None
CLI wiring:
- MeArgs struct with section filter, since, project, all, user, fields flags
- Autocorrect support for me command flags
- LoreRenderer::try_get() for safe renderer access in me module
- Robot mode field selection presets (me_items, me_activity)
- handle_me() in main.rs command dispatch
Also fixes duplicate assertions in surgical sync tests (removed 6
duplicate assert! lines that were copy-paste artifacts).
Spec: docs/lore-me-spec.md
561 lines
23 KiB
Rust
561 lines
23 KiB
Rust
// ─── Query Functions ────────────────────────────────────────────────────────
|
|
//
|
|
// SQL queries powering the `lore me` dashboard.
|
|
// Each function takes &Connection, username, optional project scope,
|
|
// and returns Result<Vec<StructType>>.
|
|
|
|
use rusqlite::Connection;
|
|
|
|
use crate::core::error::Result;
|
|
|
|
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr};
|
|
|
|
/// Stale threshold: items with no activity for 30 days are marked "stale".
|
|
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
|
|
|
// ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────
|
|
|
|
/// Query open issues assigned to the user via issue_assignees.
|
|
/// Returns issues sorted by attention state priority, then by most recently updated.
|
|
/// Attention state is computed inline using CTE-based note timestamp comparison.
|
|
pub fn query_open_issues(
|
|
conn: &Connection,
|
|
username: &str,
|
|
project_ids: &[i64],
|
|
) -> Result<Vec<MeIssue>> {
|
|
let project_clause = build_project_clause("i.project_id", project_ids);
|
|
|
|
let sql = format!(
|
|
"WITH note_ts AS (
|
|
SELECT d.issue_id,
|
|
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
|
|
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
|
|
MAX(n.created_at) AS any_ts
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE n.is_system = 0 AND d.issue_id IS NOT NULL
|
|
GROUP BY d.issue_id
|
|
)
|
|
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
|
|
CASE
|
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
THEN 'needs_attention'
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 'stale'
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 'awaiting_response'
|
|
ELSE 'not_started'
|
|
END AS attention_state
|
|
FROM issues i
|
|
JOIN issue_assignees ia ON ia.issue_id = i.id
|
|
JOIN projects p ON i.project_id = p.id
|
|
LEFT JOIN note_ts nt ON nt.issue_id = i.id
|
|
WHERE ia.username = ?1
|
|
AND i.state = 'opened'
|
|
{project_clause}
|
|
ORDER BY
|
|
CASE
|
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
THEN 0
|
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
|
|
THEN 1
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 3
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 2
|
|
ELSE 1
|
|
END,
|
|
i.updated_at DESC",
|
|
stale_ms = STALE_THRESHOLD_MS,
|
|
);
|
|
|
|
let params = build_params(username, project_ids);
|
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
|
let attention_str: String = row.get(6)?;
|
|
Ok(MeIssue {
|
|
iid: row.get(0)?,
|
|
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
|
|
project_path: row.get(2)?,
|
|
status_name: row.get(3)?,
|
|
updated_at: row.get(4)?,
|
|
web_url: row.get(5)?,
|
|
attention_state: parse_attention_state(&attention_str),
|
|
labels: Vec::new(),
|
|
})
|
|
})?;
|
|
|
|
let mut issues: Vec<MeIssue> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
populate_issue_labels(conn, &mut issues)?;
|
|
Ok(issues)
|
|
}
|
|
|
|
// ─── Authored MRs (AC-5.2, Task #8) ────────────────────────────────────────
|
|
|
|
/// Query open MRs authored by the user.
|
|
pub fn query_authored_mrs(
|
|
conn: &Connection,
|
|
username: &str,
|
|
project_ids: &[i64],
|
|
) -> Result<Vec<MeMr>> {
|
|
let project_clause = build_project_clause("m.project_id", project_ids);
|
|
|
|
let sql = format!(
|
|
"WITH note_ts AS (
|
|
SELECT d.merge_request_id,
|
|
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
|
|
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
|
|
MAX(n.created_at) AS any_ts
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
|
|
GROUP BY d.merge_request_id
|
|
)
|
|
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
|
|
m.updated_at, m.web_url,
|
|
CASE
|
|
WHEN m.draft = 1 AND NOT EXISTS (
|
|
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
|
|
) THEN 'not_ready'
|
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
THEN 'needs_attention'
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 'stale'
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 'awaiting_response'
|
|
ELSE 'not_started'
|
|
END AS attention_state
|
|
FROM merge_requests m
|
|
JOIN projects p ON m.project_id = p.id
|
|
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
|
|
WHERE m.author_username = ?1
|
|
AND m.state = 'opened'
|
|
{project_clause}
|
|
ORDER BY
|
|
CASE
|
|
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
|
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
|
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
|
|
ELSE 1
|
|
END,
|
|
m.updated_at DESC",
|
|
stale_ms = STALE_THRESHOLD_MS,
|
|
);
|
|
|
|
let params = build_params(username, project_ids);
|
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
|
let attention_str: String = row.get(7)?;
|
|
Ok(MeMr {
|
|
iid: row.get(0)?,
|
|
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
|
|
project_path: row.get(2)?,
|
|
draft: row.get::<_, i32>(3)? != 0,
|
|
detailed_merge_status: row.get(4)?,
|
|
updated_at: row.get(5)?,
|
|
web_url: row.get(6)?,
|
|
attention_state: parse_attention_state(&attention_str),
|
|
author_username: None,
|
|
labels: Vec::new(),
|
|
})
|
|
})?;
|
|
|
|
let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
populate_mr_labels(conn, &mut mrs)?;
|
|
Ok(mrs)
|
|
}
|
|
|
|
// ─── Reviewing MRs (AC-5.3, Task #9) ───────────────────────────────────────
|
|
|
|
/// Query open MRs where user is a reviewer.
|
|
pub fn query_reviewing_mrs(
|
|
conn: &Connection,
|
|
username: &str,
|
|
project_ids: &[i64],
|
|
) -> Result<Vec<MeMr>> {
|
|
let project_clause = build_project_clause("m.project_id", project_ids);
|
|
|
|
let sql = format!(
|
|
"WITH note_ts AS (
|
|
SELECT d.merge_request_id,
|
|
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
|
|
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
|
|
MAX(n.created_at) AS any_ts
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
|
|
GROUP BY d.merge_request_id
|
|
)
|
|
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
|
|
m.author_username, m.updated_at, m.web_url,
|
|
CASE
|
|
-- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
|
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
THEN 'needs_attention'
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 'stale'
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 'awaiting_response'
|
|
ELSE 'not_started'
|
|
END AS attention_state
|
|
FROM merge_requests m
|
|
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
|
JOIN projects p ON m.project_id = p.id
|
|
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
|
|
WHERE r.username = ?1
|
|
AND m.state = 'opened'
|
|
{project_clause}
|
|
ORDER BY
|
|
CASE
|
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
|
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
|
|
ELSE 1
|
|
END,
|
|
m.updated_at DESC",
|
|
stale_ms = STALE_THRESHOLD_MS,
|
|
);
|
|
|
|
let params = build_params(username, project_ids);
|
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
|
let attention_str: String = row.get(8)?;
|
|
Ok(MeMr {
|
|
iid: row.get(0)?,
|
|
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
|
|
project_path: row.get(2)?,
|
|
draft: row.get::<_, i32>(3)? != 0,
|
|
detailed_merge_status: row.get(4)?,
|
|
author_username: row.get(5)?,
|
|
updated_at: row.get(6)?,
|
|
web_url: row.get(7)?,
|
|
attention_state: parse_attention_state(&attention_str),
|
|
labels: Vec::new(),
|
|
})
|
|
})?;
|
|
|
|
let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
populate_mr_labels(conn, &mut mrs)?;
|
|
Ok(mrs)
|
|
}
|
|
|
|
// ─── Activity Feed (AC-5.4, Tasks #11-13) ──────────────────────────────────
|
|
|
|
/// Query activity events on items currently associated with the user.
|
|
/// Combines notes, state events, label events, milestone events, and
|
|
/// assignment/reviewer system notes into a unified feed sorted newest-first.
|
|
pub fn query_activity(
|
|
conn: &Connection,
|
|
username: &str,
|
|
project_ids: &[i64],
|
|
since_ms: i64,
|
|
) -> Result<Vec<MeActivityEvent>> {
|
|
// Build project filter for activity sources.
|
|
// Activity params: ?1=username, ?2=since_ms, ?3+=project_ids
|
|
let project_clause = build_project_clause_at("p.id", project_ids, 3);
|
|
|
|
// Build the "my items" subquery fragments for issue/MR association checks.
|
|
// These ensure we only see activity on items CURRENTLY associated with the user (AC-3.6).
|
|
let my_issue_check = "EXISTS (
|
|
SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
|
|
)";
|
|
let my_mr_check = "(
|
|
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
|
|
OR EXISTS (SELECT 1 FROM mr_reviewers rv WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
|
|
)";
|
|
|
|
// Source 1: Human comments on my items
|
|
let notes_sql = format!(
|
|
"SELECT n.created_at, 'note',
|
|
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
p.path_with_namespace,
|
|
n.author_username,
|
|
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
|
|
SUBSTR(n.body, 1, 200),
|
|
SUBSTR(n.body, 1, 200)
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
JOIN projects p ON d.project_id = p.id
|
|
LEFT JOIN issues i ON d.issue_id = i.id
|
|
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
|
WHERE n.is_system = 0
|
|
AND n.created_at >= ?2
|
|
{project_clause}
|
|
AND (
|
|
(d.issue_id IS NOT NULL AND {issue_check})
|
|
OR (d.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
project_clause = project_clause,
|
|
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
|
|
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
|
|
);
|
|
|
|
// Source 2: State events
|
|
let state_sql = format!(
|
|
"SELECT e.created_at, 'status_change',
|
|
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
p.path_with_namespace,
|
|
e.actor_username,
|
|
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
|
|
e.state,
|
|
NULL
|
|
FROM resource_state_events e
|
|
JOIN projects p ON e.project_id = p.id
|
|
LEFT JOIN issues i ON e.issue_id = i.id
|
|
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
|
|
WHERE e.created_at >= ?2
|
|
{project_clause}
|
|
AND (
|
|
(e.issue_id IS NOT NULL AND {issue_check})
|
|
OR (e.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
project_clause = project_clause,
|
|
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
|
|
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
|
|
);
|
|
|
|
// Source 3: Label events
|
|
let label_sql = format!(
|
|
"SELECT e.created_at, 'label_change',
|
|
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
p.path_with_namespace,
|
|
e.actor_username,
|
|
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
|
|
(e.action || ' ' || COALESCE(e.label_name, '(deleted)')),
|
|
NULL
|
|
FROM resource_label_events e
|
|
JOIN projects p ON e.project_id = p.id
|
|
LEFT JOIN issues i ON e.issue_id = i.id
|
|
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
|
|
WHERE e.created_at >= ?2
|
|
{project_clause}
|
|
AND (
|
|
(e.issue_id IS NOT NULL AND {issue_check})
|
|
OR (e.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
project_clause = project_clause,
|
|
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
|
|
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
|
|
);
|
|
|
|
// Source 4: Milestone events
|
|
let milestone_sql = format!(
|
|
"SELECT e.created_at, 'milestone_change',
|
|
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
p.path_with_namespace,
|
|
e.actor_username,
|
|
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
|
|
(e.action || ' ' || COALESCE(e.milestone_title, '(deleted)')),
|
|
NULL
|
|
FROM resource_milestone_events e
|
|
JOIN projects p ON e.project_id = p.id
|
|
LEFT JOIN issues i ON e.issue_id = i.id
|
|
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
|
|
WHERE e.created_at >= ?2
|
|
{project_clause}
|
|
AND (
|
|
(e.issue_id IS NOT NULL AND {issue_check})
|
|
OR (e.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
project_clause = project_clause,
|
|
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
|
|
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
|
|
);
|
|
|
|
// Source 5: Assignment/reviewer system notes (AC-12)
|
|
let assign_sql = format!(
|
|
"SELECT n.created_at,
|
|
CASE
|
|
WHEN LOWER(n.body) LIKE '%assigned to @%' THEN 'assign'
|
|
WHEN LOWER(n.body) LIKE '%unassigned @%' THEN 'unassign'
|
|
WHEN LOWER(n.body) LIKE '%requested review from @%' THEN 'review_request'
|
|
ELSE 'assign'
|
|
END,
|
|
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
p.path_with_namespace,
|
|
n.author_username,
|
|
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
|
|
n.body,
|
|
NULL
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
JOIN projects p ON d.project_id = p.id
|
|
LEFT JOIN issues i ON d.issue_id = i.id
|
|
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
|
WHERE n.is_system = 1
|
|
AND n.created_at >= ?2
|
|
{project_clause}
|
|
AND (
|
|
LOWER(n.body) LIKE '%assigned to @' || LOWER(?1) || '%'
|
|
OR LOWER(n.body) LIKE '%unassigned @' || LOWER(?1) || '%'
|
|
OR LOWER(n.body) LIKE '%requested review from @' || LOWER(?1) || '%'
|
|
)
|
|
AND (
|
|
(d.issue_id IS NOT NULL AND {issue_check})
|
|
OR (d.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
project_clause = project_clause,
|
|
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
|
|
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
|
|
);
|
|
|
|
let full_sql = format!(
|
|
"{notes_sql}
|
|
UNION ALL {state_sql}
|
|
UNION ALL {label_sql}
|
|
UNION ALL {milestone_sql}
|
|
UNION ALL {assign_sql}
|
|
ORDER BY 1 DESC"
|
|
);
|
|
|
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
|
params.push(Box::new(username.to_string()));
|
|
params.push(Box::new(since_ms));
|
|
for &pid in project_ids {
|
|
params.push(Box::new(pid));
|
|
}
|
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
|
|
let mut stmt = conn.prepare(&full_sql)?;
|
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
|
let event_type_str: String = row.get(1)?;
|
|
Ok(MeActivityEvent {
|
|
timestamp: row.get(0)?,
|
|
event_type: parse_event_type(&event_type_str),
|
|
entity_type: row.get(2)?,
|
|
entity_iid: row.get(3)?,
|
|
project_path: row.get(4)?,
|
|
actor: row.get(5)?,
|
|
is_own: row.get::<_, i32>(6)? != 0,
|
|
summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
|
body_preview: row.get(8)?,
|
|
})
|
|
})?;
|
|
|
|
let events: Vec<MeActivityEvent> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(events)
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
/// Parse attention state string from SQL CASE result.
|
|
fn parse_attention_state(s: &str) -> AttentionState {
|
|
match s {
|
|
"needs_attention" => AttentionState::NeedsAttention,
|
|
"not_started" => AttentionState::NotStarted,
|
|
"awaiting_response" => AttentionState::AwaitingResponse,
|
|
"stale" => AttentionState::Stale,
|
|
"not_ready" => AttentionState::NotReady,
|
|
_ => AttentionState::NotStarted,
|
|
}
|
|
}
|
|
|
|
/// Parse activity event type string from SQL.
|
|
fn parse_event_type(s: &str) -> ActivityEventType {
|
|
match s {
|
|
"note" => ActivityEventType::Note,
|
|
"status_change" => ActivityEventType::StatusChange,
|
|
"label_change" => ActivityEventType::LabelChange,
|
|
"assign" => ActivityEventType::Assign,
|
|
"unassign" => ActivityEventType::Unassign,
|
|
"review_request" => ActivityEventType::ReviewRequest,
|
|
"milestone_change" => ActivityEventType::MilestoneChange,
|
|
_ => ActivityEventType::Note,
|
|
}
|
|
}
|
|
|
|
/// Build a SQL clause for project ID filtering.
|
|
/// `start_idx` is the 1-based parameter index for the first project ID.
|
|
/// Returns empty string when no filter is needed (all projects).
|
|
fn build_project_clause_at(column: &str, project_ids: &[i64], start_idx: usize) -> String {
|
|
match project_ids.len() {
|
|
0 => String::new(),
|
|
1 => format!("AND {column} = ?{start_idx}"),
|
|
n => {
|
|
let placeholders: Vec<String> = (0..n).map(|i| format!("?{}", start_idx + i)).collect();
|
|
format!("AND {column} IN ({})", placeholders.join(","))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convenience: project clause starting at param index 2 (after username at ?1).
|
|
fn build_project_clause(column: &str, project_ids: &[i64]) -> String {
|
|
build_project_clause_at(column, project_ids, 2)
|
|
}
|
|
|
|
/// Build the parameter vector: username first, then project IDs.
|
|
fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::types::ToSql>> {
|
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
|
params.push(Box::new(username.to_string()));
|
|
for &pid in project_ids {
|
|
params.push(Box::new(pid));
|
|
}
|
|
params
|
|
}
|
|
|
|
/// Populate labels for issues via cached per-item queries.
|
|
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
|
|
if issues.is_empty() {
|
|
return Ok(());
|
|
}
|
|
for issue in issues.iter_mut() {
|
|
let mut stmt = conn.prepare_cached(
|
|
"SELECT l.name FROM labels l
|
|
JOIN issue_labels il ON l.id = il.label_id
|
|
JOIN issues i ON il.issue_id = i.id
|
|
JOIN projects p ON i.project_id = p.id
|
|
WHERE i.iid = ?1 AND p.path_with_namespace = ?2
|
|
ORDER BY l.name",
|
|
)?;
|
|
let labels: Vec<String> = stmt
|
|
.query_map(rusqlite::params![issue.iid, issue.project_path], |row| {
|
|
row.get(0)
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
issue.labels = labels;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Populate labels for MRs via cached per-item queries.
|
|
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
|
|
if mrs.is_empty() {
|
|
return Ok(());
|
|
}
|
|
for mr in mrs.iter_mut() {
|
|
let mut stmt = conn.prepare_cached(
|
|
"SELECT l.name FROM labels l
|
|
JOIN mr_labels ml ON l.id = ml.label_id
|
|
JOIN merge_requests m ON ml.merge_request_id = m.id
|
|
JOIN projects p ON m.project_id = p.id
|
|
WHERE m.iid = ?1 AND p.path_with_namespace = ?2
|
|
ORDER BY l.name",
|
|
)?;
|
|
let labels: Vec<String> = stmt
|
|
.query_map(rusqlite::params![mr.iid, mr.project_path], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
mr.labels = labels;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
#[path = "me_tests.rs"]
|
|
mod tests;
|