1 Commits

Author SHA1 Message Date
teernisse
34680f0087 feat(me): implement lore me personal work dashboard command
Complete implementation of the lore me command with:
- Config: gitlab.username field for identity resolution
- CLI: MeArgs with --issues, --mrs, --activity, --since, --project, --all, --user, --fields
- Identity: username resolution with precedence (CLI > config > error)
- Scope: project scope resolution with fuzzy matching and mutual exclusivity
- Types: AttentionState enum (5 states with sort ordering), dashboard structs
- Queries: open issues, authored MRs, reviewing MRs (all with attention state CTEs)
- Activity: 5-source feed (notes, state/label/milestone events, assignment detection)
- Human renderer: summary header, attention legend, section cards, event badges
- Robot renderer: {ok,data,meta} envelope with --fields minimal preset
- Handler: full wiring with section filtering, error paths, exit codes
- Autocorrect: me command flags registered

21 beads (bd-qpk3 through bd-32aw) implemented by 3-agent swarm.
978 tests pass, clippy clean.
2026-02-20 11:09:47 -05:00
5 changed files with 125 additions and 77 deletions

View File

@@ -97,14 +97,11 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let single_project = project_ids.len() == 1; let single_project = project_ids.len() == 1;
// 5. Parse --since (default 30d for activity feed, AC-2.3) // 5. Parse --since (default 30d for activity feed, AC-2.3)
let since_ms = match args.since.as_deref() { let since_ms = args
Some(raw) => parse_since(raw).ok_or_else(|| { .since
LoreError::Other(format!( .as_deref()
"Invalid --since value '{raw}'. Expected: 7d, 2w, 3m, YYYY-MM-DD, or Unix-ms timestamp." .and_then(parse_since)
)) .unwrap_or_else(|| crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY);
})?,
None => crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY,
};
// 6. Determine which sections to query // 6. Determine which sections to query
let show_all = args.show_all_sections(); let show_all = args.show_all_sections();
@@ -187,7 +184,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
if robot_mode { if robot_mode {
let fields = args.fields.as_deref(); let fields = args.fields.as_deref();
render_robot::print_me_json(&dashboard, elapsed_ms, fields)?; render_robot::print_me_json(&dashboard, elapsed_ms, fields);
} else if show_all { } else if show_all {
render_human::print_me_dashboard(&dashboard, single_project); render_human::print_me_dashboard(&dashboard, single_project);
} else { } else {

View File

@@ -10,9 +10,6 @@ use crate::core::error::Result;
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr}; 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) ───────────────────────────────────────── // ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────
/// Query open issues assigned to the user via issue_assignees. /// Query open issues assigned to the user via issue_assignees.
@@ -26,47 +23,62 @@ pub fn query_open_issues(
let project_clause = build_project_clause("i.project_id", project_ids); let project_clause = build_project_clause("i.project_id", project_ids);
let sql = format!( let sql = format!(
"WITH note_ts AS ( "WITH my_latest AS (
SELECT d.issue_id, SELECT d.issue_id, MAX(n.created_at) AS ts
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 FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.issue_id IS NOT NULL WHERE n.author_username = ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
others_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
any_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS 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 GROUP BY d.issue_id
) )
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url, SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
CASE CASE
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention' THEN 'needs_attention'
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale' THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response' THEN 'awaiting_response'
ELSE 'not_started' ELSE 'not_started'
END AS attention_state END AS attention_state
FROM issues i FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.id JOIN projects p ON i.project_id = p.id
LEFT JOIN note_ts nt ON nt.issue_id = i.id LEFT JOIN my_latest ml ON ml.issue_id = i.id
LEFT JOIN others_latest ol ON ol.issue_id = i.id
LEFT JOIN any_latest al ON al.issue_id = i.id
WHERE ia.username = ?1 WHERE ia.username = ?1
AND i.state = 'opened' AND i.state = 'opened'
{project_clause} {project_clause}
ORDER BY ORDER BY
CASE CASE
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 0 THEN 0
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL WHEN al.ts IS NULL AND ml.ts IS NULL
THEN 1 THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 3 THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 2 THEN 2
ELSE 1 ELSE 1
END, END,
i.updated_at DESC", i.updated_at DESC"
stale_ms = STALE_THRESHOLD_MS,
); );
let params = build_params(username, project_ids); let params = build_params(username, project_ids);
@@ -103,14 +115,28 @@ pub fn query_authored_mrs(
let project_clause = build_project_clause("m.project_id", project_ids); let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!( let sql = format!(
"WITH note_ts AS ( "WITH my_latest AS (
SELECT d.merge_request_id, SELECT d.merge_request_id, MAX(n.created_at) AS ts
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 FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS 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 GROUP BY d.merge_request_id
) )
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status, SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
@@ -119,31 +145,32 @@ pub fn query_authored_mrs(
WHEN m.draft = 1 AND NOT EXISTS ( WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready' ) THEN 'not_ready'
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention' THEN 'needs_attention'
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale' THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response' THEN 'awaiting_response'
ELSE 'not_started' ELSE 'not_started'
END AS attention_state END AS attention_state
FROM merge_requests m FROM merge_requests m
JOIN projects p ON m.project_id = p.id JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
WHERE m.author_username = ?1 WHERE m.author_username = ?1
AND m.state = 'opened' AND m.state = 'opened'
{project_clause} {project_clause}
ORDER BY ORDER BY
CASE CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4 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 ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN al.ts IS NULL AND ml.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 al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2 WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
ELSE 1 ELSE 1
END, END,
m.updated_at DESC", m.updated_at DESC"
stale_ms = STALE_THRESHOLD_MS,
); );
let params = build_params(username, project_ids); let params = build_params(username, project_ids);
@@ -182,45 +209,63 @@ pub fn query_reviewing_mrs(
let project_clause = build_project_clause("m.project_id", project_ids); let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!( let sql = format!(
"WITH note_ts AS ( "WITH my_latest AS (
SELECT d.merge_request_id, SELECT d.merge_request_id, MAX(n.created_at) AS ts
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 FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS 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 GROUP BY d.merge_request_id
) )
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status, SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.author_username, m.updated_at, m.web_url, m.author_username, m.updated_at, m.web_url,
CASE CASE
-- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists WHEN m.draft = 1 AND NOT EXISTS (
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready'
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention' THEN 'needs_attention'
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale' THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response' THEN 'awaiting_response'
ELSE 'not_started' ELSE 'not_started'
END AS attention_state END AS attention_state
FROM merge_requests m FROM merge_requests m
JOIN mr_reviewers r ON r.merge_request_id = m.id JOIN mr_reviewers r ON r.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
WHERE r.username = ?1 WHERE r.username = ?1
AND m.state = 'opened' AND m.state = 'opened'
{project_clause} {project_clause}
ORDER BY ORDER BY
CASE CASE
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0 WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2 WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
ELSE 1 ELSE 1
END, END,
m.updated_at DESC", m.updated_at DESC"
stale_ms = STALE_THRESHOLD_MS,
); );
let params = build_params(username, project_ids); let params = build_params(username, project_ids);
@@ -507,7 +552,7 @@ fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::ty
params params
} }
/// Populate labels for issues via cached per-item queries. /// Populate labels for issues (avoids N+1 when there are few issues).
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> { fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
if issues.is_empty() { if issues.is_empty() {
return Ok(()); return Ok(());
@@ -531,7 +576,7 @@ fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()
Ok(()) Ok(())
} }
/// Populate labels for MRs via cached per-item queries. /// Populate labels for MRs (avoids N+1 when there are few MRs).
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> { fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
if mrs.is_empty() { if mrs.is_empty() {
return Ok(()); return Ok(());

View File

@@ -138,7 +138,6 @@ fn print_attention_legend() {
(AttentionState::NotStarted, "not started"), (AttentionState::NotStarted, "not started"),
(AttentionState::AwaitingResponse, "awaiting response"), (AttentionState::AwaitingResponse, "awaiting response"),
(AttentionState::Stale, "stale (30d+)"), (AttentionState::Stale, "stale (30d+)"),
(AttentionState::NotReady, "draft (not ready)"),
]; ];
let legend: Vec<String> = states let legend: Vec<String> = states
@@ -370,7 +369,7 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
let s = format!("#{iid}"); let s = format!("#{iid}");
Theme::issue_ref().render(&s) Theme::issue_ref().render(&s)
} }
"mr" => { "merge_request" => {
let s = format!("!{iid}"); let s = format!("!{iid}");
Theme::mr_ref().render(&s) Theme::mr_ref().render(&s)
} }
@@ -441,7 +440,7 @@ mod tests {
#[test] #[test]
fn format_entity_ref_mr() { fn format_entity_ref_mr() {
let result = format_entity_ref("mr", 99); let result = format_entity_ref("merge_request", 99);
assert!(result.contains("99"), "got: {result}"); assert!(result.contains("99"), "got: {result}");
} }

View File

@@ -10,19 +10,20 @@ use super::types::{
// ─── Robot JSON Output (Task #18) ──────────────────────────────────────────── // ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
/// Print the full me dashboard as robot-mode JSON. /// Print the full me dashboard as robot-mode JSON.
pub fn print_me_json( pub fn print_me_json(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[String]>) {
dashboard: &MeDashboard,
elapsed_ms: u64,
fields: Option<&[String]>,
) -> crate::core::error::Result<()> {
let envelope = MeJsonEnvelope { let envelope = MeJsonEnvelope {
ok: true, ok: true,
data: MeDataJson::from_dashboard(dashboard), data: MeDataJson::from_dashboard(dashboard),
meta: RobotMeta { elapsed_ms }, meta: RobotMeta { elapsed_ms },
}; };
let mut value = serde_json::to_value(&envelope) let mut value = match serde_json::to_value(&envelope) {
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?; Ok(v) => v,
Err(e) => {
eprintln!("Error serializing me JSON: {e}");
return;
}
};
// Apply --fields filtering (Task #19) // Apply --fields filtering (Task #19)
if let Some(f) = fields { if let Some(f) = fields {
@@ -37,10 +38,10 @@ pub fn print_me_json(
crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded); crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded);
} }
let json = serde_json::to_string(&value) match serde_json::to_string(&value) {
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?; Ok(json) => println!("{json}"),
println!("{json}"); Err(e) => eprintln!("Error serializing to JSON: {e}"),
Ok(()) }
} }
// ─── JSON Envelope ─────────────────────────────────────────────────────────── // ─── JSON Envelope ───────────────────────────────────────────────────────────

View File

@@ -168,6 +168,7 @@ fn test_ingest_issue_by_iid_upserts_and_marks_dirty() {
let result = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap(); let result = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale); assert!(!result.skipped_stale);
assert!(!result.dirty_source_keys.is_empty()); assert!(!result.dirty_source_keys.is_empty());
@@ -199,6 +200,7 @@ fn test_toctou_skips_stale_issue() {
// Second ingest with same timestamp should be skipped // Second ingest with same timestamp should be skipped
let r2 = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap(); let r2 = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
assert!(r2.skipped_stale); assert!(r2.skipped_stale);
assert!(r2.skipped_stale);
assert!(r2.dirty_source_keys.is_empty()); assert!(r2.dirty_source_keys.is_empty());
// No new dirty mark // No new dirty mark
@@ -222,6 +224,7 @@ fn test_toctou_allows_newer_issue() {
let result = ingest_issue_by_iid(&conn, &config, 1, &issue_t2).unwrap(); let result = ingest_issue_by_iid(&conn, &config, 1, &issue_t2).unwrap();
assert!(!result.skipped_stale); assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
} }
#[test] #[test]
@@ -269,6 +272,7 @@ fn test_ingest_mr_by_iid_upserts_and_marks_dirty() {
let result = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap(); let result = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale); assert!(!result.skipped_stale);
assert!(!result.dirty_source_keys.is_empty()); assert!(!result.dirty_source_keys.is_empty());
@@ -295,6 +299,7 @@ fn test_toctou_skips_stale_mr() {
let r2 = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap(); let r2 = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
assert!(r2.skipped_stale); assert!(r2.skipped_stale);
assert!(r2.skipped_stale);
assert!(r2.dirty_source_keys.is_empty()); assert!(r2.dirty_source_keys.is_empty());
} }
@@ -312,6 +317,7 @@ fn test_toctou_allows_newer_mr() {
let result = ingest_mr_by_iid(&conn, &config, 1, &mr_t2).unwrap(); let result = ingest_mr_by_iid(&conn, &config, 1, &mr_t2).unwrap();
assert!(!result.skipped_stale); assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
} }
#[test] #[test]