3 Commits

Author SHA1 Message Date
teernisse
597095a283 chore: update beads tracker state
Sync beads issue database to JSONL for version control tracking.
2026-02-20 14:25:28 -05:00
teernisse
d0e88abe85 feat(me): add lore me personal work dashboard command
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
2026-02-20 14:25:20 -05:00
teernisse
cb6894798e docs: migrate agent coordination from MCP Agent Mail to Liquid Mail
Replace all MCP Agent Mail references with Liquid Mail in AGENTS.md and
CLAUDE.md. The old system used file reservations and MCP-based messaging
with inbox/outbox/thread semantics. Liquid Mail provides a simpler
post-based shared log with topic-scoped messages, decision conflict
detection, and polling via the liquid-mail CLI.

Key changes:
- Remove entire MCP Agent Mail section (identity registration, file
  reservations, macros vs granular tools, common pitfalls)
- Update Beads integration workflow to reference Liquid Mail: replace
  reservation + announce patterns with post-based progress logging and
  decision-first workflows
- Update bv scope boundary note to reference Liquid Mail
- Append full Liquid Mail integration block to CLAUDE.md: conventions,
  typical flow, decision conflicts, posting format, topic rules, context
  refresh, live updates, mapping cheat-sheet, quick reference
- Add .liquid-mail.toml project configuration (Honcho backend)
2026-02-20 14:25:08 -05:00
5 changed files with 77 additions and 125 deletions

View File

@@ -97,11 +97,14 @@ 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 = args let since_ms = match args.since.as_deref() {
.since Some(raw) => parse_since(raw).ok_or_else(|| {
.as_deref() LoreError::Other(format!(
.and_then(parse_since) "Invalid --since value '{raw}'. Expected: 7d, 2w, 3m, YYYY-MM-DD, or Unix-ms timestamp."
.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();
@@ -184,7 +187,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,6 +10,9 @@ 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.
@@ -23,62 +26,47 @@ 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 my_latest AS ( "WITH note_ts AS (
SELECT d.issue_id, MAX(n.created_at) AS ts 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 FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0 WHERE n.is_system = 0 AND d.issue_id IS NOT NULL
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 ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 'needs_attention' THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 'stale' THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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 my_latest ml ON ml.issue_id = i.id LEFT JOIN note_ts nt ON nt.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 ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 0 THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
THEN 1 THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 3 THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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);
@@ -115,28 +103,14 @@ 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 my_latest AS ( "WITH note_ts AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts 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 FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0 WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
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,
@@ -145,32 +119,31 @@ 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 ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
THEN 'needs_attention' THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 'stale' THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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 my_latest ml ON ml.merge_request_id = m.id LEFT JOIN note_ts nt ON nt.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 ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0 WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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);
@@ -209,63 +182,45 @@ 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 my_latest AS ( "WITH note_ts AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts 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 FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0 WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
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
WHEN m.draft = 1 AND NOT EXISTS ( -- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
) 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 al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 'stale' THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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 my_latest ml ON ml.merge_request_id = m.id LEFT JOIN note_ts nt ON nt.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 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);
@@ -552,7 +507,7 @@ fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::ty
params params
} }
/// Populate labels for issues (avoids N+1 when there are few issues). /// Populate labels for issues via cached per-item queries.
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(());
@@ -576,7 +531,7 @@ fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()
Ok(()) Ok(())
} }
/// Populate labels for MRs (avoids N+1 when there are few MRs). /// Populate labels for MRs via cached per-item queries.
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,6 +138,7 @@ 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
@@ -369,7 +370,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)
} }
"merge_request" => { "mr" => {
let s = format!("!{iid}"); let s = format!("!{iid}");
Theme::mr_ref().render(&s) Theme::mr_ref().render(&s)
} }
@@ -440,7 +441,7 @@ mod tests {
#[test] #[test]
fn format_entity_ref_mr() { fn format_entity_ref_mr() {
let result = format_entity_ref("merge_request", 99); let result = format_entity_ref("mr", 99);
assert!(result.contains("99"), "got: {result}"); assert!(result.contains("99"), "got: {result}");
} }

View File

@@ -10,20 +10,19 @@ 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(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[String]>) { pub fn print_me_json(
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 = match serde_json::to_value(&envelope) { let mut value = serde_json::to_value(&envelope)
Ok(v) => v, .map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
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 {
@@ -38,10 +37,10 @@ pub fn print_me_json(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[
crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded); crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded);
} }
match serde_json::to_string(&value) { let json = serde_json::to_string(&value)
Ok(json) => println!("{json}"), .map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
Err(e) => eprintln!("Error serializing to JSON: {e}"), println!("{json}");
} Ok(())
} }
// ─── JSON Envelope ─────────────────────────────────────────────────────────── // ─── JSON Envelope ───────────────────────────────────────────────────────────

View File

@@ -168,7 +168,6 @@ 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());
@@ -200,7 +199,6 @@ 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
@@ -224,7 +222,6 @@ 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]
@@ -272,7 +269,6 @@ 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());
@@ -299,7 +295,6 @@ 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());
} }
@@ -317,7 +312,6 @@ 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]