5 Commits

Author SHA1 Message Date
teernisse
0fe3737035 docs(plan): add GitLab TODOs integration design document
Captures design decisions and acceptance criteria for adding GitLab
TODO support to lore. This plan was developed through user interview
to ensure the feature aligns with actual workflows.

Key design decisions:
- Read-only scope (no mark-as-done operations)
- Three integration points: --todos flag, activity enrichment, lore todos
- Account-wide: --project does NOT filter todos (unlike issues/MRs)
- Separate signal: todos don't affect attention state calculation
- Snapshot sync: missing todos = marked done elsewhere = delete locally

The plan covers:
- Database schema (todos table + indexes)
- GitLab API client extensions
- Sync pipeline integration
- Action type handling and grouping
- CLI commands and robot mode schemas
- Non-synced project handling with [external] indicator

Implementation is organized into 5 rollout slices:
A: Schema + Client, B: Sync, C: lore todos, D: lore me, E: Polish

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 10:02:55 -05:00
teernisse
87bdbda468 feat(status): add per-entity sync counts from migration 027
Enhances sync status reporting to include granular per-entity counts
that were added in database migration 027. This provides better
visibility into what each sync run actually processed.

New fields in SyncRunInfo and robot mode JSON:
- issues_fetched / issues_ingested: issue sync counts
- mrs_fetched / mrs_ingested: merge request sync counts
- skipped_stale: entities skipped due to staleness
- docs_regenerated / docs_embedded: document pipeline counts
- warnings_count: non-fatal issues during sync

Robot mode optimization:
- Uses skip_serializing_if = "is_zero" to omit zero-value fields
- Reduces JSON payload size for typical sync runs
- Maintains backwards compatibility (fields are additive)

SQL query now reads all 8 new columns from sync_runs table,
with defensive unwrap_or(0) for NULL handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 10:02:45 -05:00
teernisse
ed987c8f71 docs: update robot-docs manifest and agent instructions for since-last-check
Updates the `lore robot-docs` manifest with comprehensive documentation
for the new since-last-check inbox feature, enabling AI agents to
discover and use the functionality programmatically.

robot-docs manifest additions:
- since_last_check response schema with cursor_iso, groups, events
- --reset-cursor flag documentation
- Design notes: cursor persistence location, --project filter behavior
- Example commands in personal_dashboard section

Agent instruction updates (AGENTS.md, CLAUDE.md):
- Added --mrs, --project, --user flags to command examples
- Added --reset-cursor example
- Aligned both files for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 10:02:37 -05:00
teernisse
ce5621f3ed feat(me): add "since last check" cursor-based inbox to dashboard
Implements a cursor-based notification inbox that surfaces actionable
events from others since the user's last `lore me` invocation. This
addresses the core UX need: "what happened while I was away?"

Event Sources (three-way UNION query):
1. Others' comments on user's open issues/MRs
2. @mentions on ANY item (not restricted to owned items)
3. Assignment/review-request system notes mentioning user

Mention Detection:
- SQL LIKE pre-filter for performance, then regex validation
- Word-boundary-aware: rejects "alice" in "@alice-bot" or "alice@corp.com"
- Domain rejection: "@alice.com" not matched (prevents email false positives)
- Punctuation tolerance: "@alice," "@alice." "(@ alice)" all match

Cursor Watermark Pattern:
- Global watermark computed from ALL projects before --project filtering
- Ensures --project display filter doesn't permanently skip events
- Cursor advances only after successful render (no data loss on errors)
- First run establishes baseline (no inbox shown), subsequent runs show delta

Output:
- Human: color-coded event badges, grouped by entity, actor + timestamp
- Robot: standard envelope with since_last_check object containing
  cursor_iso, total_event_count, and groups array with nested events

CLI additions:
- --reset-cursor flag: clears cursor (next run shows no new events)
- Autocorrect: --reset-cursor added to known me command flags

Tests cover:
- Mention with trailing comma/period/parentheses (should match)
- Email-like text "@alice.com" (should NOT match)  
- Domain-like text "@alice.example" (should NOT match)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 10:02:31 -05:00
teernisse
eac640225f feat(core): add cursor persistence module for session-based timestamps
Introduces a lightweight file-based cursor system for persisting
per-user timestamps across CLI invocations. This enables "since last
check" semantics where `lore me` can track what the user has seen.

Key design decisions:
- Per-user cursor files: ~/.local/share/lore/me_cursor_<username>.json
- Atomic writes via temp-file + rename pattern (crash-safe)
- Graceful degradation: missing/corrupt files return None
- Username sanitization: non-safe chars replaced with underscore

The cursor module provides three operations:
- read_cursor(username) -> Option<i64>: read last-check timestamp
- write_cursor(username, timestamp_ms): atomically persist timestamp  
- reset_cursor(username): delete cursor file (no-op if missing)

Tests cover: missing file, roundtrip, per-user isolation, reset
isolation, JSON validity after overwrites, corrupt file handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 10:02:13 -05:00
16 changed files with 1231 additions and 18 deletions

View File

@@ -626,8 +626,12 @@ lore --robot embed
# Personal work dashboard # Personal work dashboard
lore --robot me lore --robot me
lore --robot me --issues lore --robot me --issues
lore --robot me --mrs
lore --robot me --activity --since 7d lore --robot me --activity --since 7d
lore --robot me --project group/repo
lore --robot me --user jdoe
lore --robot me --fields minimal lore --robot me --fields minimal
lore --robot me --reset-cursor
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas) # Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
lore robot-docs lore robot-docs

View File

@@ -645,8 +645,12 @@ lore --robot embed
# Personal work dashboard # Personal work dashboard
lore --robot me lore --robot me
lore --robot me --issues lore --robot me --issues
lore --robot me --mrs
lore --robot me --activity --since 7d lore --robot me --activity --since 7d
lore --robot me --project group/repo
lore --robot me --user jdoe
lore --robot me --fields minimal lore --robot me --fields minimal
lore --robot me --reset-cursor
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas) # Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
lore robot-docs lore robot-docs

View File

@@ -0,0 +1,278 @@
---
plan: true
title: "GitLab TODOs Integration"
status: proposed
iteration: 1
target_iterations: 3
beads_revision: 1
related_plans: []
created: 2026-02-23
updated: 2026-02-23
---
# GitLab TODOs Integration
## Summary
Add GitLab TODO support to lore. Todos are fetched during sync, stored locally, and surfaced through:
1. A new `--todos` section in `lore me`
2. Enrichment of the activity feed in `lore me`
3. A standalone `lore todos` command
**Scope:** Read-only. No mark-as-done operations.
---
## Design Decisions (from interview)
| Decision | Choice |
|----------|--------|
| Write operations | **Read-only** — no mark-as-done |
| Storage | **Persist locally** in SQLite |
| Integration | Three-way: activity enrichment + `--todos` flag + `lore todos` |
| Action types | Core only: assigned, mentioned, directly_addressed, approval_required, build_failed, unmergeable |
| Niche actions | Skip display (but store): merge_train_removed, member_access_requested, marked |
| Project filter | **Always account-wide**`--project` does NOT filter todos |
| Sync timing | During normal `lore sync` |
| Non-synced projects | Include with `[external]` indicator |
| Attention state | **Separate signal** — todos don't boost attention |
| Summary header | Include pending todo count |
| Grouping | By action type: Assignments \| Mentions \| Approvals \| Build Issues |
| History | **Pending only** — done todos not tracked |
| `lore todos` filters | **None** — show all pending, simple |
| Robot mode | Yes, standard envelope |
| Target types | All GitLab supports (Issue, MR, Epic, Commit, etc.) |
---
## Out of Scope
- Write operations (mark as done)
- Done todo history tracking
- Filters on `lore todos` command
- Todo-based attention state boosting
- Notification settings API integration (deferred to separate plan)
---
## Acceptance Criteria
### AC-1: Database Schema
- [ ] **AC-1.1:** Create `todos` table with columns:
- `id` INTEGER PRIMARY KEY
- `gitlab_todo_id` INTEGER NOT NULL UNIQUE
- `project_id` INTEGER REFERENCES projects(id) ON DELETE SET NULL (nullable for non-synced)
- `target_type` TEXT NOT NULL (Issue, MergeRequest, Commit, Epic, etc.)
- `target_id` INTEGER (GitLab ID of target entity)
- `target_iid` INTEGER (IID for issues/MRs, nullable)
- `target_url` TEXT NOT NULL
- `target_title` TEXT
- `action_name` TEXT NOT NULL (assigned, mentioned, etc.)
- `author_id` INTEGER
- `author_username` TEXT
- `body` TEXT (the todo message/snippet)
- `state` TEXT NOT NULL (pending)
- `created_at` INTEGER NOT NULL (epoch ms)
- `updated_at` INTEGER NOT NULL (epoch ms)
- `synced_at` INTEGER NOT NULL (epoch ms)
- `project_path` TEXT (for display even if project not synced)
- [ ] **AC-1.2:** Create index `idx_todos_state_action` on `(state, action_name)`
- [ ] **AC-1.3:** Create index `idx_todos_target` on `(target_type, target_id)`
- [ ] **AC-1.4:** Create index `idx_todos_created` on `(created_at DESC)`
- [ ] **AC-1.5:** Migration increments schema version
### AC-2: GitLab API Client
- [ ] **AC-2.1:** Add `fetch_todos()` method to GitLab client
- [ ] **AC-2.2:** Fetch only `state=pending` todos
- [ ] **AC-2.3:** Handle pagination (use existing pagination pattern)
- [ ] **AC-2.4:** Parse all target types GitLab returns
- [ ] **AC-2.5:** Extract project path from `target_url` for non-synced projects
### AC-3: Sync Pipeline
- [ ] **AC-3.1:** Add todos sync step to `lore sync` pipeline
- [ ] **AC-3.2:** Sync todos AFTER issues/MRs (ordering consistency)
- [ ] **AC-3.3:** Snapshot semantics: fetch all pending, upsert, delete missing (= marked done elsewhere)
- [ ] **AC-3.4:** Track `synced_at` timestamp
- [ ] **AC-3.5:** Log todo sync stats: fetched, inserted, updated, deleted
- [ ] **AC-3.6:** Add `--no-todos` flag to skip todo sync
### AC-4: Action Type Handling
- [ ] **AC-4.1:** Store ALL action types from GitLab
- [ ] **AC-4.2:** Display only core actions:
- `assigned` — assigned to issue/MR
- `mentioned`@mentioned in comment
- `directly_addressed`@mentioned at start of comment
- `approval_required` — approval needed on MR
- `build_failed` — CI failed on your MR
- `unmergeable` — merge conflicts on your MR
- [ ] **AC-4.3:** Skip display (but store) niche actions: `merge_train_removed`, `member_access_requested`, `marked`
### AC-5: `lore todos` Command
- [ ] **AC-5.1:** New subcommand `lore todos` (alias: `todo`)
- [ ] **AC-5.2:** Display all pending todos, no filters
- [ ] **AC-5.3:** Group by action type: Assignments | Mentions | Approvals | Build Issues
- [ ] **AC-5.4:** Per-todo display: target title, project path, author, age, action
- [ ] **AC-5.5:** Flag non-synced project todos with `[external]` indicator
- [ ] **AC-5.6:** Human-readable output with colors/icons
- [ ] **AC-5.7:** Robot mode: standard `{ok, data, meta}` envelope
### AC-6: `lore me --todos` Section
- [ ] **AC-6.1:** Add `--todos` flag to `MeArgs`
- [ ] **AC-6.2:** When no section flags: show todos in full dashboard
- [ ] **AC-6.3:** When `--todos` flag only: show only todos section
- [ ] **AC-6.4:** Todos section grouped by action type
- [ ] **AC-6.5:** Todos NOT filtered by `--project` (always account-wide)
- [ ] **AC-6.6:** Robot mode includes `todos` array in dashboard response
### AC-7: `lore me` Summary Header
- [ ] **AC-7.1:** Add `pending_todo_count` to `MeSummary` struct
- [ ] **AC-7.2:** Display todo count in summary line (human mode)
- [ ] **AC-7.3:** Include `pending_todo_count` in robot mode summary
### AC-8: Activity Feed Enrichment
- [ ] **AC-8.1:** Todos with local issue/MR target appear in activity feed
- [ ] **AC-8.2:** New `ActivityEventType::Todo` variant
- [ ] **AC-8.3:** Todo events show: action type, author, target in summary
- [ ] **AC-8.4:** Sorted chronologically with other activity events
- [ ] **AC-8.5:** Respect `--since` filter on todo `created_at`
### AC-9: Non-Synced Project Handling
- [ ] **AC-9.1:** Store todos even if target project not in config
- [ ] **AC-9.2:** Display `[external]` indicator for non-synced project todos
- [ ] **AC-9.3:** Show project path (extracted from target URL)
- [ ] **AC-9.4:** Graceful fallback when target title unavailable
### AC-10: Attention State
- [ ] **AC-10.1:** Attention state calculation remains note-based (unchanged)
- [ ] **AC-10.2:** Todos are separate signal, do not affect attention state
- [ ] **AC-10.3:** Document this design decision in code comments
### AC-11: Robot Mode Schema
- [ ] **AC-11.1:** `lore todos --robot` returns:
```json
{
"ok": true,
"data": {
"todos": [{
"id": 123,
"gitlab_todo_id": 456,
"action": "mentioned",
"target_type": "Issue",
"target_iid": 42,
"target_title": "Fix login bug",
"target_url": "https://...",
"project_path": "group/repo",
"author_username": "jdoe",
"body": "Hey @you, can you look at this?",
"created_at_iso": "2026-02-20T10:00:00Z",
"is_external": false
}],
"counts": {
"total": 8,
"assigned": 2,
"mentioned": 5,
"approval_required": 1,
"build_failed": 0,
"unmergeable": 0
}
},
"meta": {"elapsed_ms": 42}
}
```
- [ ] **AC-11.2:** `lore me --robot` includes `todos` and `pending_todo_count` in response
- [ ] **AC-11.3:** Support `--fields minimal` for token efficiency
### AC-12: Documentation
- [ ] **AC-12.1:** Update CLAUDE.md with `lore todos` command reference
- [ ] **AC-12.2:** Update `lore robot-docs` manifest with todos schema
- [ ] **AC-12.3:** Add todos to CLI help output
### AC-13: Quality Gates
- [ ] **AC-13.1:** `cargo check --all-targets` passes
- [ ] **AC-13.2:** `cargo clippy --all-targets -- -D warnings` passes
- [ ] **AC-13.3:** `cargo fmt --check` passes
- [ ] **AC-13.4:** `cargo test` passes with new tests
---
## Technical Notes
### GitLab API Endpoint
```
GET /api/v4/todos?state=pending
```
Response fields: id, project, author, action_name, target_type, target, target_url, body, state, created_at, updated_at
### Sync Deletion Strategy
Snapshot semantics: a todo disappearing from API response means it was marked done elsewhere. Delete from local DB to stay in sync.
### Project Path Extraction
For non-synced projects, extract path from `target_url`:
```
https://gitlab.com/group/subgroup/repo/-/issues/42
^^^^^^^^^^^^^^^^^ extract this
```
### Action Type Grouping
| Group | Actions |
|-------|---------|
| Assignments | `assigned` |
| Mentions | `mentioned`, `directly_addressed` |
| Approvals | `approval_required` |
| Build Issues | `build_failed`, `unmergeable` |
---
## Rollout Slices
### Slice A: Schema + Client
- Migration 028
- `GitLabTodo` type
- `fetch_todos()` client method
- Unit tests for deserialization
### Slice B: Sync Integration
- `src/ingestion/todos.rs`
- Integrate into `lore sync`
- `--no-todos` flag
- Sync stats
### Slice C: `lore todos` Command
- CLI args + dispatch
- Human + robot rendering
- Autocorrect aliases
### Slice D: `lore me` Integration
- `--todos` flag
- Summary count
- Activity feed enrichment
### Slice E: Polish
- Edge case tests
- Documentation updates
- `robot-docs` manifest
---
## References
- [GitLab To-Do List API](https://docs.gitlab.com/api/todos/)
- [GitLab User Todos](https://docs.gitlab.com/user/todos/)

View File

@@ -297,6 +297,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
"--all", "--all",
"--user", "--user",
"--fields", "--fields",
"--reset-cursor",
], ],
), ),
]; ];

View File

@@ -710,6 +710,131 @@ fn activity_review_request_system_note() {
assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest); assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
} }
// ─── Since-Last-Check Mention Tests ─────────────────────────────────────────
#[test]
fn since_last_check_detects_mention_with_trailing_comma() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"please review this @alice, thanks",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected mention with comma to match");
}
#[test]
fn since_last_check_ignores_email_like_text() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"contact alice at foo@alice.com",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 0, "email text should not count as mention");
}
#[test]
fn since_last_check_detects_mention_with_trailing_period() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"please review this @alice.",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected mention with period to match");
}
#[test]
fn since_last_check_detects_mention_inside_parentheses() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"thanks (@alice) for the update",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected parenthesized mention to match");
}
#[test]
fn since_last_check_ignores_domain_like_text() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"@alice.com is the old hostname",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(
total_events, 0,
"domain-like text should not count as mention"
);
}
// ─── Helper Tests ────────────────────────────────────────────────────────── // ─── Helper Tests ──────────────────────────────────────────────────────────
#[test] #[test]
@@ -734,6 +859,7 @@ fn parse_attention_state_all_variants() {
#[test] #[test]
fn parse_event_type_all_variants() { fn parse_event_type_all_variants() {
assert_eq!(parse_event_type("note"), ActivityEventType::Note); assert_eq!(parse_event_type("note"), ActivityEventType::Note);
assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note);
assert_eq!( assert_eq!(
parse_event_type("status_change"), parse_event_type("status_change"),
ActivityEventType::StatusChange ActivityEventType::StatusChange

View File

@@ -9,14 +9,18 @@ use rusqlite::Connection;
use crate::Config; use crate::Config;
use crate::cli::MeArgs; use crate::cli::MeArgs;
use crate::core::cursor;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
use crate::core::project::resolve_project; use crate::core::project::resolve_project;
use crate::core::time::parse_since; use crate::core::time::parse_since;
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs}; use self::queries::{
use self::types::{AttentionState, MeDashboard, MeSummary}; query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs,
query_since_last_check,
};
use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck};
/// Default activity lookback: 1 day in milliseconds. /// Default activity lookback: 1 day in milliseconds.
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1; const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
@@ -72,6 +76,20 @@ pub fn resolve_project_scope(
/// summary computation → dashboard assembly → rendering. /// summary computation → dashboard assembly → rendering.
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let username = resolve_username(args, config)?;
// 0. Handle --reset-cursor early return
if args.reset_cursor {
cursor::reset_cursor(username)
.map_err(|e| LoreError::Other(format!("reset cursor: {e}")))?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
render_robot::print_cursor_reset_json(elapsed_ms)?;
} else {
println!("Cursor reset for @{username}. Next `lore me` will establish a new baseline.");
}
return Ok(());
}
// 1. Open DB // 1. Open DB
let db_path = get_db_path(config.storage.db_path.as_deref()); let db_path = get_db_path(config.storage.db_path.as_deref());
@@ -89,14 +107,11 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
)); ));
} }
// 3. Resolve username // 3. Resolve project scope
let username = resolve_username(args, config)?;
// 4. Resolve project scope
let project_ids = resolve_project_scope(&conn, args, config)?; let project_ids = resolve_project_scope(&conn, args, config)?;
let single_project = project_ids.len() == 1; let single_project = project_ids.len() == 1;
// 5. Parse --since (default 1d for activity feed) // 4. Parse --since (default 1d for activity feed)
let since_ms = match args.since.as_deref() { let since_ms = match args.since.as_deref() {
Some(raw) => parse_since(raw).ok_or_else(|| { Some(raw) => parse_since(raw).ok_or_else(|| {
LoreError::Other(format!( LoreError::Other(format!(
@@ -106,13 +121,13 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
None => 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 // 5. Determine which sections to query
let show_all = args.show_all_sections(); let show_all = args.show_all_sections();
let want_issues = show_all || args.issues; let want_issues = show_all || args.issues;
let want_mrs = show_all || args.mrs; let want_mrs = show_all || args.mrs;
let want_activity = show_all || args.activity; let want_activity = show_all || args.activity;
// 7. Run queries for requested sections // 6. Run queries for requested sections
let open_issues = if want_issues { let open_issues = if want_issues {
query_open_issues(&conn, username, &project_ids)? query_open_issues(&conn, username, &project_ids)?
} else { } else {
@@ -137,7 +152,32 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
Vec::new() Vec::new()
}; };
// 8. Compute summary // 6b. Since-last-check (cursor-based inbox)
let cursor_ms = cursor::read_cursor(username);
// Capture global watermark BEFORE project filtering so --project doesn't
// permanently skip events from other projects.
let mut global_watermark: Option<i64> = None;
let since_last_check = if let Some(prev_cursor) = cursor_ms {
let groups = query_since_last_check(&conn, username, prev_cursor)?;
// Watermark from ALL groups (unfiltered) — this is the true high-water mark
global_watermark = groups.iter().map(|g| g.latest_timestamp).max();
// If --project was passed, filter groups by project for display only
let groups = if !project_ids.is_empty() {
filter_groups_by_project_ids(&conn, &groups, &project_ids)
} else {
groups
};
let total = groups.iter().map(|g| g.events.len()).sum();
Some(SinceLastCheck {
cursor_ms: prev_cursor,
groups,
total_event_count: total,
})
} else {
None // First run — no section shown
};
// 7. Compute summary
let needs_attention_count = open_issues let needs_attention_count = open_issues
.iter() .iter()
.filter(|i| i.attention_state == AttentionState::NeedsAttention) .filter(|i| i.attention_state == AttentionState::NeedsAttention)
@@ -171,7 +211,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
needs_attention_count, needs_attention_count,
}; };
// 9. Assemble dashboard // 8. Assemble dashboard
let dashboard = MeDashboard { let dashboard = MeDashboard {
username: username.to_string(), username: username.to_string(),
since_ms: Some(since_ms), since_ms: Some(since_ms),
@@ -180,9 +220,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
open_mrs_authored, open_mrs_authored,
reviewing_mrs, reviewing_mrs,
activity, activity,
since_last_check,
}; };
// 10. Render // 9. Render
let elapsed_ms = start.elapsed().as_millis() as u64; let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode { if robot_mode {
@@ -200,9 +241,43 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
); );
} }
// 10. Advance cursor AFTER successful render (watermark pattern)
// Uses max event timestamp from UNFILTERED results so --project filtering
// doesn't permanently skip events from other projects.
let watermark = global_watermark.unwrap_or_else(crate::core::time::now_ms);
cursor::write_cursor(username, watermark)
.map_err(|e| LoreError::Other(format!("write cursor: {e}")))?;
Ok(()) Ok(())
} }
/// Filter since-last-check groups to only those matching the given project IDs.
/// Used when --project narrows the display scope (cursor is still global).
fn filter_groups_by_project_ids(
conn: &Connection,
groups: &[types::SinceCheckGroup],
project_ids: &[i64],
) -> Vec<types::SinceCheckGroup> {
// Resolve project IDs to paths for matching
let paths: HashSet<String> = project_ids
.iter()
.filter_map(|pid| {
conn.query_row(
"SELECT path_with_namespace FROM projects WHERE id = ?1",
rusqlite::params![pid],
|row| row.get::<_, String>(0),
)
.ok()
})
.collect();
groups
.iter()
.filter(|g| paths.contains(&g.project_path))
.cloned()
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -243,6 +318,7 @@ mod tests {
all: false, all: false,
user: user.map(String::from), user: user.map(String::from),
fields: None, fields: None,
reset_cursor: false,
} }
} }

View File

@@ -8,7 +8,13 @@ use rusqlite::Connection;
use crate::core::error::Result; use crate::core::error::Result;
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr}; use regex::Regex;
use std::collections::HashMap;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr, SinceCheckEvent,
SinceCheckGroup,
};
/// Stale threshold: items with no activity for 30 days are marked "stale". /// Stale threshold: items with no activity for 30 days are marked "stale".
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000; const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
@@ -464,6 +470,223 @@ pub fn query_activity(
Ok(events) 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
}
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
/// Parse attention state string from SQL CASE result. /// Parse attention state string from SQL CASE result.
@@ -482,6 +705,7 @@ fn parse_attention_state(s: &str) -> AttentionState {
fn parse_event_type(s: &str) -> ActivityEventType { fn parse_event_type(s: &str) -> ActivityEventType {
match s { match s {
"note" => ActivityEventType::Note, "note" => ActivityEventType::Note,
"mention_note" => ActivityEventType::Note,
"status_change" => ActivityEventType::StatusChange, "status_change" => ActivityEventType::StatusChange,
"label_change" => ActivityEventType::LabelChange, "label_change" => ActivityEventType::LabelChange,
"assign" => ActivityEventType::Assign, "assign" => ActivityEventType::Assign,
@@ -492,6 +716,46 @@ fn parse_event_type(s: &str) -> ActivityEventType {
} }
} }
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. /// Build a SQL clause for project ID filtering.
/// `start_idx` is the 1-based parameter index for the first project ID. /// `start_idx` is the 1-based parameter index for the first project ID.
/// Returns empty string when no filter is needed (all projects). /// Returns empty string when no filter is needed (all projects).

View File

@@ -2,6 +2,7 @@ use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell
use super::types::{ use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
SinceLastCheck,
}; };
// ─── Layout Helpers ───────────────────────────────────────────────────────── // ─── Layout Helpers ─────────────────────────────────────────────────────────
@@ -475,10 +476,113 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
} }
} }
// ─── Since Last Check ────────────────────────────────────────────────────────
/// Print the "since last check" section at the top of the dashboard.
pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bool) {
let relative = render::format_relative_time(since.cursor_ms);
if since.groups.is_empty() {
println!(
"\n {}",
Theme::dim().render(&format!(
"No new events since {} ({relative})",
render::format_datetime(since.cursor_ms),
))
);
return;
}
println!(
"{}",
render::section_divider(&format!("Since Last Check ({relative})"))
);
for group in &since.groups {
// Entity header: !247 Fix race condition...
let ref_str = match group.entity_type.as_str() {
"issue" => format!("#{}", group.entity_iid),
"mr" => format!("!{}", group.entity_iid),
_ => format!("{}:{}", group.entity_type, group.entity_iid),
};
let ref_style = match group.entity_type.as_str() {
"issue" => Theme::issue_ref(),
"mr" => Theme::mr_ref(),
_ => Theme::bold(),
};
println!();
println!(
" {} {}",
ref_style.render(&ref_str),
Theme::bold().render(&render::truncate(&group.entity_title, title_width(20))),
);
if !single_project {
println!(" {}", Theme::dim().render(&group.project_path));
}
// Sub-events as indented rows
let summary_max = title_width(42);
let mut table = Table::new()
.columns(3)
.indent(6)
.align(2, Align::Right)
.max_width(1, summary_max);
for event in &group.events {
let badge = activity_badge_label(&event.event_type);
let badge_style = activity_badge_style(&event.event_type);
let actor_prefix = event
.actor
.as_deref()
.map(|a| format!("@{a} "))
.unwrap_or_default();
let clean_summary = event.summary.replace('\n', " ");
let summary_text = format!("{actor_prefix}{clean_summary}");
let time = render::format_relative_time_compact(event.timestamp);
table.add_row(vec![
StyledCell::styled(badge, badge_style),
StyledCell::plain(summary_text),
StyledCell::styled(time, Theme::dim()),
]);
}
let rendered = table.render();
for (line, event) in rendered.lines().zip(group.events.iter()) {
println!("{line}");
if let Some(preview) = &event.body_preview
&& !preview.is_empty()
{
let truncated = render::truncate(preview, 60);
println!(
" {}",
Theme::dim().render(&format!("\"{truncated}\""))
);
}
}
}
// Footer
println!(
"\n {}",
Theme::dim().render(&format!(
"{} events across {} items",
since.total_event_count,
since.groups.len()
))
);
}
// ─── Full Dashboard ────────────────────────────────────────────────────────── // ─── Full Dashboard ──────────────────────────────────────────────────────────
/// Render the complete human-mode dashboard. /// Render the complete human-mode dashboard.
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) { pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
if let Some(ref since) = dashboard.since_last_check {
print_since_last_check_section(since, single_project);
}
print_summary_header(&dashboard.summary, &dashboard.username); print_summary_header(&dashboard.summary, &dashboard.username);
print_issues_section(&dashboard.open_issues, single_project); print_issues_section(&dashboard.open_issues, single_project);
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
@@ -495,6 +599,9 @@ pub fn print_me_dashboard_filtered(
show_mrs: bool, show_mrs: bool,
show_activity: bool, show_activity: bool,
) { ) {
if let Some(ref since) = dashboard.since_last_check {
print_since_last_check_section(since, single_project);
}
print_summary_header(&dashboard.summary, &dashboard.username); print_summary_header(&dashboard.summary, &dashboard.username);
if show_issues { if show_issues {

View File

@@ -5,6 +5,7 @@ use crate::core::time::ms_to_iso;
use super::types::{ use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
}; };
// ─── Robot JSON Output (Task #18) ──────────────────────────────────────────── // ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
@@ -43,6 +44,27 @@ pub fn print_me_json(
Ok(()) Ok(())
} }
/// Print `--reset-cursor` response using standard robot envelope.
pub fn print_cursor_reset_json(elapsed_ms: u64) -> crate::core::error::Result<()> {
let value = cursor_reset_envelope_json(elapsed_ms);
let json = serde_json::to_string(&value)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
println!("{json}");
Ok(())
}
fn cursor_reset_envelope_json(elapsed_ms: u64) -> serde_json::Value {
serde_json::json!({
"ok": true,
"data": {
"cursor_reset": true
},
"meta": {
"elapsed_ms": elapsed_ms
}
})
}
// ─── JSON Envelope ─────────────────────────────────────────────────────────── // ─── JSON Envelope ───────────────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
@@ -57,6 +79,8 @@ struct MeDataJson {
username: String, username: String,
since_iso: Option<String>, since_iso: Option<String>,
summary: SummaryJson, summary: SummaryJson,
#[serde(skip_serializing_if = "Option::is_none")]
since_last_check: Option<SinceLastCheckJson>,
open_issues: Vec<IssueJson>, open_issues: Vec<IssueJson>,
open_mrs_authored: Vec<MrJson>, open_mrs_authored: Vec<MrJson>,
reviewing_mrs: Vec<MrJson>, reviewing_mrs: Vec<MrJson>,
@@ -69,6 +93,7 @@ impl MeDataJson {
username: d.username.clone(), username: d.username.clone(),
since_iso: d.since_ms.map(ms_to_iso), since_iso: d.since_ms.map(ms_to_iso),
summary: SummaryJson::from(&d.summary), summary: SummaryJson::from(&d.summary),
since_last_check: d.since_last_check.as_ref().map(SinceLastCheckJson::from),
open_issues: d.open_issues.iter().map(IssueJson::from).collect(), open_issues: d.open_issues.iter().map(IssueJson::from).collect(),
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(), open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(), reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(),
@@ -197,6 +222,67 @@ impl From<&MeActivityEvent> for ActivityJson {
} }
} }
// ─── Since Last Check ────────────────────────────────────────────────────────
#[derive(Serialize)]
struct SinceLastCheckJson {
cursor_iso: String,
total_event_count: usize,
groups: Vec<SinceCheckGroupJson>,
}
impl From<&SinceLastCheck> for SinceLastCheckJson {
fn from(s: &SinceLastCheck) -> Self {
Self {
cursor_iso: ms_to_iso(s.cursor_ms),
total_event_count: s.total_event_count,
groups: s.groups.iter().map(SinceCheckGroupJson::from).collect(),
}
}
}
#[derive(Serialize)]
struct SinceCheckGroupJson {
entity_type: String,
entity_iid: i64,
entity_title: String,
project: String,
events: Vec<SinceCheckEventJson>,
}
impl From<&SinceCheckGroup> for SinceCheckGroupJson {
fn from(g: &SinceCheckGroup) -> Self {
Self {
entity_type: g.entity_type.clone(),
entity_iid: g.entity_iid,
entity_title: g.entity_title.clone(),
project: g.project_path.clone(),
events: g.events.iter().map(SinceCheckEventJson::from).collect(),
}
}
}
#[derive(Serialize)]
struct SinceCheckEventJson {
timestamp_iso: String,
event_type: String,
actor: Option<String>,
summary: String,
body_preview: Option<String>,
}
impl From<&SinceCheckEvent> for SinceCheckEventJson {
fn from(e: &SinceCheckEvent) -> Self {
Self {
timestamp_iso: ms_to_iso(e.timestamp),
event_type: event_type_str(&e.event_type),
actor: e.actor.clone(),
summary: e.summary.clone(),
body_preview: e.body_preview.clone(),
}
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/// Convert `AttentionState` to its programmatic string representation. /// Convert `AttentionState` to its programmatic string representation.
@@ -331,4 +417,12 @@ mod tests {
assert!(!json.is_own); assert!(!json.is_own);
assert_eq!(json.body_preview, Some("This looks good".to_string())); assert_eq!(json.body_preview, Some("This looks good".to_string()));
} }
#[test]
fn cursor_reset_envelope_includes_meta_elapsed_ms() {
let value = cursor_reset_envelope_json(17);
assert_eq!(value["ok"], serde_json::json!(true));
assert_eq!(value["data"]["cursor_reset"], serde_json::json!(true));
assert_eq!(value["meta"]["elapsed_ms"], serde_json::json!(17));
}
} }

View File

@@ -86,6 +86,34 @@ pub struct MeActivityEvent {
pub body_preview: Option<String>, pub body_preview: Option<String>,
} }
/// A single actionable event in the "since last check" section.
#[derive(Clone)]
pub struct SinceCheckEvent {
pub timestamp: i64,
pub event_type: ActivityEventType,
pub actor: Option<String>,
pub summary: String,
pub body_preview: Option<String>,
}
/// Events grouped by entity for the "since last check" section.
#[derive(Clone)]
pub struct SinceCheckGroup {
pub entity_type: String,
pub entity_iid: i64,
pub entity_title: String,
pub project_path: String,
pub events: Vec<SinceCheckEvent>,
pub latest_timestamp: i64,
}
/// The complete "since last check" result.
pub struct SinceLastCheck {
pub cursor_ms: i64,
pub groups: Vec<SinceCheckGroup>,
pub total_event_count: usize,
}
/// The complete dashboard result. /// The complete dashboard result.
pub struct MeDashboard { pub struct MeDashboard {
pub username: String, pub username: String,
@@ -95,4 +123,5 @@ pub struct MeDashboard {
pub open_mrs_authored: Vec<MeMr>, pub open_mrs_authored: Vec<MeMr>,
pub reviewing_mrs: Vec<MeMr>, pub reviewing_mrs: Vec<MeMr>,
pub activity: Vec<MeActivityEvent>, pub activity: Vec<MeActivityEvent>,
pub since_last_check: Option<SinceLastCheck>,
} }

View File

@@ -12,6 +12,10 @@ use crate::core::time::{format_full_datetime, ms_to_iso};
const RECENT_RUNS_LIMIT: usize = 10; const RECENT_RUNS_LIMIT: usize = 10;
fn is_zero(value: &i64) -> bool {
*value == 0
}
#[derive(Debug)] #[derive(Debug)]
pub struct SyncRunInfo { pub struct SyncRunInfo {
pub id: i64, pub id: i64,
@@ -24,6 +28,15 @@ pub struct SyncRunInfo {
pub total_items_processed: i64, pub total_items_processed: i64,
pub total_errors: i64, pub total_errors: i64,
pub stages: Option<Vec<StageTiming>>, pub stages: Option<Vec<StageTiming>>,
// Per-entity counts (from migration 027)
pub issues_fetched: i64,
pub issues_ingested: i64,
pub mrs_fetched: i64,
pub mrs_ingested: i64,
pub skipped_stale: i64,
pub docs_regenerated: i64,
pub docs_embedded: i64,
pub warnings_count: i64,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -68,7 +81,9 @@ pub fn run_sync_status(config: &Config) -> Result<SyncStatusResult> {
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> { fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, started_at, finished_at, status, command, error, "SELECT id, started_at, finished_at, status, command, error,
run_id, total_items_processed, total_errors, metrics_json run_id, total_items_processed, total_errors, metrics_json,
issues_fetched, issues_ingested, mrs_fetched, mrs_ingested,
skipped_stale, docs_regenerated, docs_embedded, warnings_count
FROM sync_runs FROM sync_runs
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT ?1", LIMIT ?1",
@@ -91,6 +106,14 @@ fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunIn
total_items_processed: row.get::<_, Option<i64>>(7)?.unwrap_or(0), total_items_processed: row.get::<_, Option<i64>>(7)?.unwrap_or(0),
total_errors: row.get::<_, Option<i64>>(8)?.unwrap_or(0), total_errors: row.get::<_, Option<i64>>(8)?.unwrap_or(0),
stages, stages,
issues_fetched: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
issues_ingested: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
mrs_fetched: row.get::<_, Option<i64>>(12)?.unwrap_or(0),
mrs_ingested: row.get::<_, Option<i64>>(13)?.unwrap_or(0),
skipped_stale: row.get::<_, Option<i64>>(14)?.unwrap_or(0),
docs_regenerated: row.get::<_, Option<i64>>(15)?.unwrap_or(0),
docs_embedded: row.get::<_, Option<i64>>(16)?.unwrap_or(0),
warnings_count: row.get::<_, Option<i64>>(17)?.unwrap_or(0),
}) })
})? })?
.collect(); .collect();
@@ -198,6 +221,23 @@ struct SyncRunJsonInfo {
error: Option<String>, error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
stages: Option<Vec<StageTiming>>, stages: Option<Vec<StageTiming>>,
// Per-entity counts
#[serde(skip_serializing_if = "is_zero")]
issues_fetched: i64,
#[serde(skip_serializing_if = "is_zero")]
issues_ingested: i64,
#[serde(skip_serializing_if = "is_zero")]
mrs_fetched: i64,
#[serde(skip_serializing_if = "is_zero")]
mrs_ingested: i64,
#[serde(skip_serializing_if = "is_zero")]
skipped_stale: i64,
#[serde(skip_serializing_if = "is_zero")]
docs_regenerated: i64,
#[serde(skip_serializing_if = "is_zero")]
docs_embedded: i64,
#[serde(skip_serializing_if = "is_zero")]
warnings_count: i64,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -237,6 +277,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
total_errors: run.total_errors, total_errors: run.total_errors,
error: run.error.clone(), error: run.error.clone(),
stages: run.stages.clone(), stages: run.stages.clone(),
issues_fetched: run.issues_fetched,
issues_ingested: run.issues_ingested,
mrs_fetched: run.mrs_fetched,
mrs_ingested: run.mrs_ingested,
skipped_stale: run.skipped_stale,
docs_regenerated: run.docs_regenerated,
docs_embedded: run.docs_embedded,
warnings_count: run.warnings_count,
} }
}) })
.collect(); .collect();

View File

@@ -1095,6 +1095,10 @@ pub struct MeArgs {
/// Select output fields (comma-separated, or 'minimal' preset) /// Select output fields (comma-separated, or 'minimal' preset)
#[arg(long, help_heading = "Output", value_delimiter = ',')] #[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>, pub fields: Option<Vec<String>>,
/// Reset the since-last-check cursor (next run shows no new events)
#[arg(long, help_heading = "Output")]
pub reset_cursor: bool,
} }
impl MeArgs { impl MeArgs {

152
src/core/cursor.rs Normal file
View File

@@ -0,0 +1,152 @@
// ─── Me Cursor Persistence ──────────────────────────────────────────────────
//
// File-based cursor for the "since last check" section of `lore me`.
// Stores per-user timestamps in ~/.local/share/lore/me_cursor_<username>.json.
use std::io;
use std::io::Write;
use serde::{Deserialize, Serialize};
use super::paths::get_cursor_path;
#[derive(Serialize, Deserialize)]
struct CursorFile {
last_check_ms: i64,
}
/// Read the last-check cursor. Returns `None` if the file doesn't exist or is corrupt.
pub fn read_cursor(username: &str) -> Option<i64> {
let path = get_cursor_path(username);
let data = std::fs::read_to_string(path).ok()?;
let cursor: CursorFile = serde_json::from_str(&data).ok()?;
Some(cursor.last_check_ms)
}
/// Write the last-check cursor atomically.
pub fn write_cursor(username: &str, timestamp_ms: i64) -> io::Result<()> {
let path = get_cursor_path(username);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
let cursor = CursorFile {
last_check_ms: timestamp_ms,
};
let json = serde_json::to_string(&cursor).map_err(io::Error::other)?;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("me_cursor.json");
let temp_path = parent.join(format!(".{file_name}.{nonce}.tmp"));
{
let mut temp_file = std::fs::File::create(&temp_path)?;
temp_file.write_all(json.as_bytes())?;
temp_file.sync_all()?;
}
std::fs::rename(&temp_path, &path)?;
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cursor path has no parent directory",
))
}
/// Reset the cursor by deleting the file. No-op if it doesn't exist.
pub fn reset_cursor(username: &str) -> io::Result<()> {
let path = get_cursor_path(username);
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn with_temp_xdg_data_home<T>(f: impl FnOnce() -> T) -> T {
let _guard = env_lock().lock().unwrap();
let previous = std::env::var_os("XDG_DATA_HOME");
let dir = tempfile::tempdir().unwrap();
// SAFETY: test-only scoped env override.
unsafe { std::env::set_var("XDG_DATA_HOME", dir.path()) };
let result = f();
match previous {
Some(value) => {
// SAFETY: restoring prior environment for test isolation.
unsafe { std::env::set_var("XDG_DATA_HOME", value) };
}
None => {
// SAFETY: restoring prior environment for test isolation.
unsafe { std::env::remove_var("XDG_DATA_HOME") };
}
}
result
}
#[test]
fn read_cursor_returns_none_when_missing() {
with_temp_xdg_data_home(|| {
assert_eq!(read_cursor("alice"), None);
});
}
#[test]
fn cursor_roundtrip() {
with_temp_xdg_data_home(|| {
write_cursor("alice", 1_700_000_000_000).unwrap();
assert_eq!(read_cursor("alice"), Some(1_700_000_000_000));
});
}
#[test]
fn cursor_isolated_per_user() {
with_temp_xdg_data_home(|| {
write_cursor("alice", 100).unwrap();
write_cursor("bob", 200).unwrap();
assert_eq!(read_cursor("alice"), Some(100));
assert_eq!(read_cursor("bob"), Some(200));
});
}
#[test]
fn reset_cursor_only_affects_target_user() {
with_temp_xdg_data_home(|| {
write_cursor("alice", 100).unwrap();
write_cursor("bob", 200).unwrap();
reset_cursor("alice").unwrap();
assert_eq!(read_cursor("alice"), None);
assert_eq!(read_cursor("bob"), Some(200));
});
}
#[test]
fn cursor_write_keeps_valid_json() {
with_temp_xdg_data_home(|| {
write_cursor("alice", 111).unwrap();
write_cursor("alice", 222).unwrap();
let data = std::fs::read_to_string(get_cursor_path("alice")).unwrap();
let parsed: CursorFile = serde_json::from_str(&data).unwrap();
assert_eq!(parsed.last_check_ms, 222);
});
}
#[test]
fn parse_corrupt_json_returns_none() {
let bad_json = "not json at all";
let parsed: Option<CursorFile> = serde_json::from_str(bad_json).ok();
assert!(parsed.is_none());
}
}

View File

@@ -2,6 +2,7 @@ pub mod backoff;
pub mod config; pub mod config;
#[cfg(unix)] #[cfg(unix)]
pub mod cron; pub mod cron;
pub mod cursor;
pub mod db; pub mod db;
pub mod dependent_queue; pub mod dependent_queue;
pub mod error; pub mod error;

View File

@@ -40,6 +40,20 @@ pub fn get_log_dir(config_override: Option<&str>) -> PathBuf {
get_data_dir().join("logs") get_data_dir().join("logs")
} }
pub fn get_cursor_path(username: &str) -> PathBuf {
let safe_username: String = username
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
}
})
.collect();
get_data_dir().join(format!("me_cursor_{safe_username}.json"))
}
pub fn get_backup_dir(config_override: Option<&str>) -> PathBuf { pub fn get_backup_dir(config_override: Option<&str>) -> PathBuf {
if let Some(path) = config_override { if let Some(path) = config_override {
return PathBuf::from(path); return PathBuf::from(path);

View File

@@ -2956,8 +2956,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
} }
}, },
"me": { "me": {
"description": "Personal work dashboard: open issues, authored/reviewing MRs, activity feed with computed attention states", "description": "Personal work dashboard: open issues, authored/reviewing MRs, activity feed, and cursor-based since-last-check inbox with computed attention states",
"flags": ["--issues", "--mrs", "--activity", "--since <period>", "-p/--project <path>", "--all", "--user <username>", "--fields <list|minimal>"], "flags": ["--issues", "--mrs", "--activity", "--since <period>", "-p/--project <path>", "--all", "--user <username>", "--fields <list|minimal>", "--reset-cursor"],
"example": "lore --robot me", "example": "lore --robot me",
"response_schema": { "response_schema": {
"ok": "bool", "ok": "bool",
@@ -2965,6 +2965,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"username": "string", "username": "string",
"since_iso": "string?", "since_iso": "string?",
"summary": {"project_count": "int", "open_issue_count": "int", "authored_mr_count": "int", "reviewing_mr_count": "int", "needs_attention_count": "int"}, "summary": {"project_count": "int", "open_issue_count": "int", "authored_mr_count": "int", "reviewing_mr_count": "int", "needs_attention_count": "int"},
"since_last_check": "{cursor_iso:string, total_event_count:int, groups:[{entity_type:string, entity_iid:int, entity_title:string, project:string, events:[{timestamp_iso:string, event_type:string, actor:string?, summary:string, body_preview:string?}]}]}?",
"open_issues": "[{project:string, iid:int, title:string, state:string, attention_state:string, status_name:string?, labels:[string], updated_at_iso:string, web_url:string?}]", "open_issues": "[{project:string, iid:int, title:string, state:string, attention_state:string, status_name:string?, labels:[string], updated_at_iso:string, web_url:string?}]",
"open_mrs_authored": "[{project:string, iid:int, title:string, state:string, attention_state:string, draft:bool, detailed_merge_status:string?, author_username:string?, labels:[string], updated_at_iso:string, web_url:string?}]", "open_mrs_authored": "[{project:string, iid:int, title:string, state:string, attention_state:string, draft:bool, detailed_merge_status:string?, author_username:string?, labels:[string], updated_at_iso:string, web_url:string?}]",
"reviewing_mrs": "[same as open_mrs_authored]", "reviewing_mrs": "[same as open_mrs_authored]",
@@ -2981,7 +2982,9 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"event_types": "note | status_change | label_change | assign | unassign | review_request | milestone_change", "event_types": "note | status_change | label_change | assign | unassign | review_request | milestone_change",
"section_flags": "If none of --issues/--mrs/--activity specified, all sections returned", "section_flags": "If none of --issues/--mrs/--activity specified, all sections returned",
"since_default": "1d for activity feed", "since_default": "1d for activity feed",
"issue_filter": "Only In Progress / In Review status issues shown" "issue_filter": "Only In Progress / In Review status issues shown",
"since_last_check": "Cursor-based inbox showing events since last run. Null on first run (no cursor yet). Groups events by entity (issue/MR). Sources: others' comments on your items, @mentions, assignment/review-request notes. Cursor auto-advances after each run. Use --reset-cursor to clear.",
"cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_<username>.json. --project filters display only for since-last-check; cursor still advances for all projects for that user."
} }
}, },
"robot-docs": { "robot-docs": {
@@ -3013,7 +3016,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"embed: Generate vector embeddings for semantic search via Ollama", "embed: Generate vector embeddings for semantic search via Ollama",
"cron: Automated sync scheduling (Unix)", "cron: Automated sync scheduling (Unix)",
"token: Secure token management with masked display", "token: Secure token management with masked display",
"me: Personal work dashboard with attention states, activity feed, and needs-attention triage" "me: Personal work dashboard with attention states, activity feed, cursor-based since-last-check inbox, and needs-attention triage"
], ],
"read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)." "read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)."
}); });
@@ -3080,6 +3083,14 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"lore --robot sync --issue 7 -p group/project", "lore --robot sync --issue 7 -p group/project",
"lore --robot sync --issue 7 --mr 10 -p group/project", "lore --robot sync --issue 7 --mr 10 -p group/project",
"lore --robot sync --issue 7 -p group/project --preflight-only" "lore --robot sync --issue 7 -p group/project --preflight-only"
],
"personal_dashboard": [
"lore --robot me",
"lore --robot me --issues",
"lore --robot me --activity --since 7d",
"lore --robot me --project group/repo",
"lore --robot me --fields minimal",
"lore --robot me --reset-cursor"
] ]
}); });