The `query_mentioned_in` SQL previously joined notes directly against the full issues/merge_requests tables, with per-row subqueries for author/assignee/reviewer exclusion. On large databases this produced pathological query plans where SQLite scanned the entire notes table before filtering to relevant entities. Refactor into a dedicated `build_mentioned_in_sql()` builder that: 1. Pre-filters candidate issues and MRs into MATERIALIZED CTEs (state open OR recently closed, not authored by user, not assigned/reviewing). This narrows the working set before any notes join. 2. Computes note timestamps (my_ts, others_ts, any_ts) as separate MATERIALIZED CTEs scoped to candidate entities only, rather than scanning all notes. 3. Joins mention-bearing notes against the pre-filtered candidates, avoiding the full-table scans. Also adds a test verifying that authored issues are excluded from the mentions results, and a unit test asserting all four CTEs are materialized. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1136 lines
44 KiB
Rust
1136 lines
44 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 regex::Regex;
|
|
use std::collections::HashMap;
|
|
|
|
use super::types::{
|
|
ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMention, MeMr, SinceCheckEvent,
|
|
SinceCheckGroup,
|
|
};
|
|
|
|
/// Stale threshold: items with no activity for 30 days are marked "stale".
|
|
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
|
|
|
// ─── Attention Reason ───────────────────────────────────────────────────────
|
|
|
|
/// Format a human-readable duration from a millisecond epoch to now.
|
|
/// Returns e.g. "3 hours", "2 days", "1 week".
|
|
fn relative_duration(ms_epoch: i64) -> String {
|
|
let diff = crate::core::time::now_ms() - ms_epoch;
|
|
if diff < 60_000 {
|
|
return "moments".to_string();
|
|
}
|
|
let (n, unit) = match diff {
|
|
d if d < 3_600_000 => (d / 60_000, "minute"),
|
|
d if d < 86_400_000 => (d / 3_600_000, "hour"),
|
|
d if d < 604_800_000 => (d / 86_400_000, "day"),
|
|
d if d < 2_592_000_000 => (d / 604_800_000, "week"),
|
|
d => (d / 2_592_000_000, "month"),
|
|
};
|
|
if n == 1 {
|
|
format!("1 {unit}")
|
|
} else {
|
|
format!("{n} {unit}s")
|
|
}
|
|
}
|
|
|
|
/// Build a human-readable reason explaining why the attention state was set.
|
|
pub(super) fn format_attention_reason(
|
|
state: &AttentionState,
|
|
my_ts: Option<i64>,
|
|
others_ts: Option<i64>,
|
|
any_ts: Option<i64>,
|
|
) -> String {
|
|
match state {
|
|
AttentionState::NotReady => "Draft with no reviewers assigned".to_string(),
|
|
AttentionState::Stale => {
|
|
if let Some(ts) = any_ts {
|
|
format!("No activity for {}", relative_duration(ts))
|
|
} else {
|
|
"No activity for over 30 days".to_string()
|
|
}
|
|
}
|
|
AttentionState::NeedsAttention => {
|
|
let others_ago = others_ts
|
|
.map(|ts| format!("{} ago", relative_duration(ts)))
|
|
.unwrap_or_else(|| "recently".to_string());
|
|
if let Some(ts) = my_ts {
|
|
format!(
|
|
"Others replied {}; you last commented {} ago",
|
|
others_ago,
|
|
relative_duration(ts)
|
|
)
|
|
} else {
|
|
format!("Others commented {}; you haven't replied", others_ago)
|
|
}
|
|
}
|
|
AttentionState::AwaitingResponse => {
|
|
if let Some(ts) = my_ts {
|
|
format!("You replied {} ago; awaiting others", relative_duration(ts))
|
|
} else {
|
|
"Awaiting response from others".to_string()
|
|
}
|
|
}
|
|
AttentionState::NotStarted => "No discussion yet".to_string(),
|
|
}
|
|
}
|
|
|
|
// ─── 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.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 'stale'
|
|
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.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 'awaiting_response'
|
|
ELSE 'not_started'
|
|
END AS attention_state,
|
|
nt.my_ts, nt.others_ts, nt.any_ts
|
|
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'
|
|
AND (i.status_name COLLATE NOCASE IN ('In Progress', 'In Review') OR i.status_name IS NULL)
|
|
{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)
|
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
|
|
THEN 0
|
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
|
|
THEN 1
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
|
|
THEN 2
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 3
|
|
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)?;
|
|
let my_ts: Option<i64> = row.get(7)?;
|
|
let others_ts: Option<i64> = row.get(8)?;
|
|
let any_ts: Option<i64> = row.get(9)?;
|
|
let state = parse_attention_state(&attention_str);
|
|
let reason = format_attention_reason(&state, my_ts, others_ts, any_ts);
|
|
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: state,
|
|
attention_reason: reason,
|
|
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.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 'stale'
|
|
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.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 'awaiting_response'
|
|
ELSE 'not_started'
|
|
END AS attention_state,
|
|
nt.my_ts, nt.others_ts, nt.any_ts
|
|
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)
|
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
|
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
|
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)?;
|
|
let my_ts: Option<i64> = row.get(8)?;
|
|
let others_ts: Option<i64> = row.get(9)?;
|
|
let any_ts: Option<i64> = row.get(10)?;
|
|
let state = parse_attention_state(&attention_str);
|
|
let reason = format_attention_reason(&state, my_ts, others_ts, any_ts);
|
|
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: state,
|
|
attention_reason: reason,
|
|
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.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
|
THEN 'stale'
|
|
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.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
THEN 'awaiting_response'
|
|
ELSE 'not_started'
|
|
END AS attention_state,
|
|
nt.my_ts, nt.others_ts, nt.any_ts
|
|
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)
|
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
|
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
|
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
|
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)?;
|
|
let my_ts: Option<i64> = row.get(9)?;
|
|
let others_ts: Option<i64> = row.get(10)?;
|
|
let any_ts: Option<i64> = row.get(11)?;
|
|
let state = parse_attention_state(&attention_str);
|
|
let reason = format_attention_reason(&state, my_ts, others_ts, any_ts);
|
|
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: state,
|
|
attention_reason: reason,
|
|
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
|
|
// AND currently open (AC-3.6). Without the state filter, activity would include
|
|
// events on closed/merged items that don't appear in the dashboard lists.
|
|
let my_issue_check = "EXISTS (
|
|
SELECT 1 FROM issue_assignees ia
|
|
JOIN issues i2 ON ia.issue_id = i2.id
|
|
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
|
|
)";
|
|
let my_mr_check = "(
|
|
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
|
|
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
|
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id
|
|
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
|
|
)";
|
|
|
|
// 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),
|
|
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 = 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
|
|
LIMIT 100"
|
|
);
|
|
|
|
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)
|
|
}
|
|
|
|
// ─── Since Last Check (cursor-based inbox) ──────────────────────────────────
|
|
|
|
/// Raw row from the since-last-check UNION query.
|
|
struct RawSinceCheckRow {
|
|
timestamp: i64,
|
|
event_type: String,
|
|
entity_type: String,
|
|
entity_iid: i64,
|
|
entity_title: String,
|
|
project_path: String,
|
|
actor: Option<String>,
|
|
summary: String,
|
|
body_preview: Option<String>,
|
|
is_mention_source: bool,
|
|
mention_body: Option<String>,
|
|
}
|
|
|
|
/// Query actionable events from others since `cursor_ms`.
|
|
/// Returns events from three sources:
|
|
/// 1. Others' comments on my open items
|
|
/// 2. @mentions on any item (not restricted to my items)
|
|
/// 3. Assignment/review-request system notes mentioning me
|
|
pub fn query_since_last_check(
|
|
conn: &Connection,
|
|
username: &str,
|
|
cursor_ms: i64,
|
|
) -> Result<Vec<SinceCheckGroup>> {
|
|
// Build the "my items" subquery fragments (reused from activity).
|
|
let my_issue_check = "EXISTS (
|
|
SELECT 1 FROM issue_assignees ia
|
|
JOIN issues i2 ON ia.issue_id = i2.id
|
|
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
|
|
)";
|
|
let my_mr_check = "(
|
|
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
|
|
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
|
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id
|
|
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
|
|
)";
|
|
|
|
// Source 1: Others' comments on my open items
|
|
let source1 = format!(
|
|
"SELECT n.created_at, 'note',
|
|
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
COALESCE(i.title, m.title),
|
|
p.path_with_namespace,
|
|
n.author_username,
|
|
SUBSTR(n.body, 1, 200),
|
|
NULL,
|
|
0,
|
|
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 = 0
|
|
AND n.created_at > ?2
|
|
AND n.author_username != ?1
|
|
AND (
|
|
(d.issue_id IS NOT NULL AND {issue_check})
|
|
OR (d.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
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: @mentions on ANY item (not restricted to my items)
|
|
// Word-boundary-aware matching to reduce false positives
|
|
let source2 = format!(
|
|
"SELECT n.created_at, 'mention_note',
|
|
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
|
COALESCE(i.iid, m.iid),
|
|
COALESCE(i.title, m.title),
|
|
p.path_with_namespace,
|
|
n.author_username,
|
|
SUBSTR(n.body, 1, 200),
|
|
NULL,
|
|
1,
|
|
n.body
|
|
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
|
|
AND n.author_username != ?1
|
|
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
|
|
AND NOT (
|
|
(d.issue_id IS NOT NULL AND {issue_check})
|
|
OR (d.merge_request_id IS NOT NULL AND {mr_check})
|
|
)",
|
|
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 3: Assignment/review-request system notes mentioning me
|
|
let source3 = "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),
|
|
COALESCE(i.title, m.title),
|
|
p.path_with_namespace,
|
|
n.author_username,
|
|
n.body,
|
|
NULL,
|
|
0,
|
|
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
|
|
AND n.author_username != ?1
|
|
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) || '%'
|
|
)"
|
|
.to_string();
|
|
|
|
let full_sql = format!(
|
|
"{source1}
|
|
UNION ALL {source2}
|
|
UNION ALL {source3}
|
|
ORDER BY 1 DESC
|
|
LIMIT 200"
|
|
);
|
|
|
|
let params: Vec<Box<dyn rusqlite::types::ToSql>> =
|
|
vec![Box::new(username.to_string()), Box::new(cursor_ms)];
|
|
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| {
|
|
Ok(RawSinceCheckRow {
|
|
timestamp: row.get(0)?,
|
|
event_type: row.get(1)?,
|
|
entity_type: row.get(2)?,
|
|
entity_iid: row.get(3)?,
|
|
entity_title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
|
project_path: row.get(5)?,
|
|
actor: row.get(6)?,
|
|
summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
|
body_preview: row.get(8)?,
|
|
is_mention_source: row.get::<_, i32>(9)? != 0,
|
|
mention_body: row.get(10)?,
|
|
})
|
|
})?;
|
|
|
|
let mention_re = build_exact_mention_regex(username);
|
|
let raw_events: Vec<RawSinceCheckRow> = rows
|
|
.collect::<std::result::Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.filter(|row| {
|
|
!row.is_mention_source
|
|
|| row
|
|
.mention_body
|
|
.as_deref()
|
|
.is_some_and(|body| contains_exact_mention(body, &mention_re))
|
|
})
|
|
.collect();
|
|
Ok(group_since_check_events(raw_events))
|
|
}
|
|
|
|
/// Group flat event rows by entity, sort groups newest-first, events within oldest-first.
|
|
fn group_since_check_events(rows: Vec<RawSinceCheckRow>) -> Vec<SinceCheckGroup> {
|
|
// Key: (entity_type, entity_iid, project_path)
|
|
let mut groups: HashMap<(String, i64, String), SinceCheckGroup> = HashMap::new();
|
|
|
|
for row in rows {
|
|
let key = (
|
|
row.entity_type.clone(),
|
|
row.entity_iid,
|
|
row.project_path.clone(),
|
|
);
|
|
let group = groups.entry(key).or_insert_with(|| SinceCheckGroup {
|
|
entity_type: row.entity_type.clone(),
|
|
entity_iid: row.entity_iid,
|
|
entity_title: row.entity_title.clone(),
|
|
project_path: row.project_path.clone(),
|
|
events: Vec::new(),
|
|
latest_timestamp: 0,
|
|
});
|
|
|
|
if row.timestamp > group.latest_timestamp {
|
|
group.latest_timestamp = row.timestamp;
|
|
}
|
|
|
|
group.events.push(SinceCheckEvent {
|
|
timestamp: row.timestamp,
|
|
event_type: parse_event_type(&row.event_type),
|
|
actor: row.actor,
|
|
summary: row.summary,
|
|
body_preview: row.body_preview,
|
|
});
|
|
}
|
|
|
|
let mut result: Vec<SinceCheckGroup> = groups.into_values().collect();
|
|
// Sort groups newest-first
|
|
result.sort_by_key(|g| std::cmp::Reverse(g.latest_timestamp));
|
|
// Sort events within each group oldest-first (read top-to-bottom)
|
|
for group in &mut result {
|
|
group.events.sort_by_key(|e| e.timestamp);
|
|
}
|
|
result
|
|
}
|
|
|
|
// ─── Mentioned In (issues/MRs where user is @mentioned but not formally associated)
|
|
|
|
/// Raw row from the mentioned-in query.
|
|
struct RawMentionRow {
|
|
entity_type: String,
|
|
iid: i64,
|
|
title: String,
|
|
project_path: String,
|
|
state: String,
|
|
updated_at: i64,
|
|
web_url: Option<String>,
|
|
my_ts: Option<i64>,
|
|
others_ts: Option<i64>,
|
|
any_ts: Option<i64>,
|
|
mention_body: String,
|
|
}
|
|
|
|
fn build_mentioned_in_sql(project_clause: &str) -> String {
|
|
format!(
|
|
"WITH candidate_issues AS MATERIALIZED (
|
|
SELECT i.id, i.iid, i.title, p.path_with_namespace, i.state,
|
|
i.updated_at, i.web_url
|
|
FROM issues i
|
|
JOIN projects p ON i.project_id = p.id
|
|
WHERE (i.state = 'opened' OR (i.state = 'closed' AND i.updated_at > ?2))
|
|
AND (i.author_username IS NULL OR i.author_username != ?1)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM issue_assignees ia
|
|
WHERE ia.issue_id = i.id AND ia.username = ?1
|
|
)
|
|
{project_clause}
|
|
),
|
|
candidate_mrs AS MATERIALIZED (
|
|
SELECT m.id, m.iid, m.title, p.path_with_namespace, m.state,
|
|
m.updated_at, m.web_url
|
|
FROM merge_requests m
|
|
JOIN projects p ON m.project_id = p.id
|
|
WHERE (m.state = 'opened'
|
|
OR (m.state IN ('merged', 'closed') AND m.updated_at > ?2))
|
|
AND m.author_username != ?1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM mr_reviewers rv
|
|
WHERE rv.merge_request_id = m.id AND rv.username = ?1
|
|
)
|
|
{project_clause}
|
|
),
|
|
note_ts_issue AS MATERIALIZED (
|
|
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
|
|
JOIN candidate_issues ci ON ci.id = d.issue_id
|
|
WHERE n.is_system = 0
|
|
GROUP BY d.issue_id
|
|
),
|
|
note_ts_mr AS MATERIALIZED (
|
|
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
|
|
JOIN candidate_mrs cm ON cm.id = d.merge_request_id
|
|
WHERE n.is_system = 0
|
|
GROUP BY d.merge_request_id
|
|
)
|
|
-- Issue mentions (scoped to candidate entities only)
|
|
SELECT 'issue', ci.iid, ci.title, ci.path_with_namespace, ci.state,
|
|
ci.updated_at, ci.web_url,
|
|
nt.my_ts, nt.others_ts, nt.any_ts,
|
|
n.body
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
JOIN candidate_issues ci ON ci.id = d.issue_id
|
|
LEFT JOIN note_ts_issue nt ON nt.issue_id = ci.id
|
|
WHERE n.is_system = 0
|
|
AND n.author_username != ?1
|
|
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
|
|
UNION ALL
|
|
-- MR mentions (scoped to candidate entities only)
|
|
SELECT 'mr', cm.iid, cm.title, cm.path_with_namespace, cm.state,
|
|
cm.updated_at, cm.web_url,
|
|
nt.my_ts, nt.others_ts, nt.any_ts,
|
|
n.body
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
JOIN candidate_mrs cm ON cm.id = d.merge_request_id
|
|
LEFT JOIN note_ts_mr nt ON nt.merge_request_id = cm.id
|
|
WHERE n.is_system = 0
|
|
AND n.author_username != ?1
|
|
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
|
|
ORDER BY 6 DESC
|
|
LIMIT 500",
|
|
)
|
|
}
|
|
|
|
/// Query issues and MRs where the user is @mentioned but not assigned/authored/reviewing.
|
|
///
|
|
/// Includes open items unconditionally, plus recently-closed/merged items
|
|
/// (where `updated_at > recency_cutoff_ms`).
|
|
///
|
|
/// Returns deduplicated results sorted by attention priority then recency.
|
|
pub fn query_mentioned_in(
|
|
conn: &Connection,
|
|
username: &str,
|
|
project_ids: &[i64],
|
|
recency_cutoff_ms: i64,
|
|
) -> Result<Vec<MeMention>> {
|
|
let project_clause = build_project_clause_at("p.id", project_ids, 3);
|
|
// Materialized CTEs avoid pathological query plans for project-scoped mentions.
|
|
let sql = build_mentioned_in_sql(&project_clause);
|
|
|
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
|
params.push(Box::new(username.to_string()));
|
|
params.push(Box::new(recency_cutoff_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 mention_re = build_exact_mention_regex(username);
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
|
Ok(RawMentionRow {
|
|
entity_type: row.get(0)?,
|
|
iid: row.get(1)?,
|
|
title: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
|
project_path: row.get(3)?,
|
|
state: row.get(4)?,
|
|
updated_at: row.get(5)?,
|
|
web_url: row.get(6)?,
|
|
my_ts: row.get(7)?,
|
|
others_ts: row.get(8)?,
|
|
any_ts: row.get(9)?,
|
|
mention_body: row.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
|
})
|
|
})?;
|
|
|
|
let raw: Vec<RawMentionRow> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
// Post-filter with exact mention regex and deduplicate by entity
|
|
let mut seen: HashMap<(String, i64, String), RawMentionRow> = HashMap::new();
|
|
for row in raw {
|
|
if !contains_exact_mention(&row.mention_body, &mention_re) {
|
|
continue;
|
|
}
|
|
let key = (row.entity_type.clone(), row.iid, row.project_path.clone());
|
|
// Keep the first occurrence (most recent due to ORDER BY updated_at DESC)
|
|
seen.entry(key).or_insert(row);
|
|
}
|
|
|
|
let mut mentions: Vec<MeMention> = seen
|
|
.into_values()
|
|
.map(|row| {
|
|
let state = compute_mention_attention(row.my_ts, row.others_ts, row.any_ts);
|
|
let reason = format_attention_reason(&state, row.my_ts, row.others_ts, row.any_ts);
|
|
MeMention {
|
|
entity_type: row.entity_type,
|
|
iid: row.iid,
|
|
title: row.title,
|
|
project_path: row.project_path,
|
|
state: row.state,
|
|
attention_state: state,
|
|
attention_reason: reason,
|
|
updated_at: row.updated_at,
|
|
web_url: row.web_url,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Sort by attention priority (needs_attention first), then by updated_at DESC
|
|
mentions.sort_by(|a, b| {
|
|
a.attention_state
|
|
.cmp(&b.attention_state)
|
|
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
|
});
|
|
|
|
Ok(mentions)
|
|
}
|
|
|
|
/// Compute attention state for a mentioned-in item.
|
|
/// Same logic as the other sections, but without the not_ready variant
|
|
/// since it's less relevant for mention-only items.
|
|
fn compute_mention_attention(
|
|
my_ts: Option<i64>,
|
|
others_ts: Option<i64>,
|
|
any_ts: Option<i64>,
|
|
) -> AttentionState {
|
|
// Stale check
|
|
if let Some(ts) = any_ts
|
|
&& ts < crate::core::time::now_ms() - STALE_THRESHOLD_MS
|
|
{
|
|
return AttentionState::Stale;
|
|
}
|
|
// Others commented after me (or I never engaged but others have)
|
|
if let Some(ots) = others_ts
|
|
&& my_ts.is_none_or(|mts| ots > mts)
|
|
{
|
|
return AttentionState::NeedsAttention;
|
|
}
|
|
// I replied and my reply is >= others' latest
|
|
if let Some(mts) = my_ts
|
|
&& mts >= others_ts.unwrap_or(0)
|
|
{
|
|
return AttentionState::AwaitingResponse;
|
|
}
|
|
AttentionState::NotStarted
|
|
}
|
|
|
|
// ─── 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,
|
|
"mention_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,
|
|
}
|
|
}
|
|
|
|
fn build_exact_mention_regex(username: &str) -> Regex {
|
|
let escaped = regex::escape(username);
|
|
let pattern = format!(r"(?i)@{escaped}");
|
|
Regex::new(&pattern).expect("mention regex must compile")
|
|
}
|
|
|
|
fn contains_exact_mention(body: &str, mention_re: &Regex) -> bool {
|
|
for m in mention_re.find_iter(body) {
|
|
let start = m.start();
|
|
let end = m.end();
|
|
|
|
let prev = body[..start].chars().next_back();
|
|
if prev.is_some_and(is_username_char) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(next) = body[end..].chars().next() {
|
|
// Reject domain-like continuations such as "@alice.com"
|
|
if next == '.' {
|
|
let after_dot = body[end + next.len_utf8()..].chars().next();
|
|
if after_dot.is_some_and(is_username_char) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if is_username_char(next) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn is_username_char(ch: char) -> bool {
|
|
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
|
|
}
|
|
|
|
/// 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;
|