Compare commits
5 Commits
c5843bd823
...
0fe3737035
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe3737035 | ||
|
|
87bdbda468 | ||
|
|
ed987c8f71 | ||
|
|
ce5621f3ed | ||
|
|
eac640225f |
@@ -626,8 +626,12 @@ lore --robot embed
|
||||
# Personal work dashboard
|
||||
lore --robot me
|
||||
lore --robot me --issues
|
||||
lore --robot me --mrs
|
||||
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 --reset-cursor
|
||||
|
||||
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||
lore robot-docs
|
||||
|
||||
@@ -645,8 +645,12 @@ lore --robot embed
|
||||
# Personal work dashboard
|
||||
lore --robot me
|
||||
lore --robot me --issues
|
||||
lore --robot me --mrs
|
||||
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 --reset-cursor
|
||||
|
||||
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||
lore robot-docs
|
||||
|
||||
278
plans/gitlab-todos-notifications-integration.md
Normal file
278
plans/gitlab-todos-notifications-integration.md
Normal 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/)
|
||||
@@ -297,6 +297,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--all",
|
||||
"--user",
|
||||
"--fields",
|
||||
"--reset-cursor",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -710,6 +710,131 @@ fn activity_review_request_system_note() {
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -734,6 +859,7 @@ fn parse_attention_state_all_variants() {
|
||||
#[test]
|
||||
fn parse_event_type_all_variants() {
|
||||
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
|
||||
assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note);
|
||||
assert_eq!(
|
||||
parse_event_type("status_change"),
|
||||
ActivityEventType::StatusChange
|
||||
|
||||
@@ -9,14 +9,18 @@ use rusqlite::Connection;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::MeArgs;
|
||||
use crate::core::cursor;
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::parse_since;
|
||||
|
||||
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
|
||||
use self::types::{AttentionState, MeDashboard, MeSummary};
|
||||
use self::queries::{
|
||||
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.
|
||||
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
||||
@@ -72,6 +76,20 @@ pub fn resolve_project_scope(
|
||||
/// summary computation → dashboard assembly → rendering.
|
||||
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
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
|
||||
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
|
||||
let username = resolve_username(args, config)?;
|
||||
|
||||
// 4. Resolve project scope
|
||||
// 3. Resolve project scope
|
||||
let project_ids = resolve_project_scope(&conn, args, config)?;
|
||||
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() {
|
||||
Some(raw) => parse_since(raw).ok_or_else(|| {
|
||||
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,
|
||||
};
|
||||
|
||||
// 6. Determine which sections to query
|
||||
// 5. Determine which sections to query
|
||||
let show_all = args.show_all_sections();
|
||||
let want_issues = show_all || args.issues;
|
||||
let want_mrs = show_all || args.mrs;
|
||||
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 {
|
||||
query_open_issues(&conn, username, &project_ids)?
|
||||
} else {
|
||||
@@ -137,7 +152,32 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
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
|
||||
.iter()
|
||||
.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,
|
||||
};
|
||||
|
||||
// 9. Assemble dashboard
|
||||
// 8. Assemble dashboard
|
||||
let dashboard = MeDashboard {
|
||||
username: username.to_string(),
|
||||
since_ms: Some(since_ms),
|
||||
@@ -180,9 +220,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
open_mrs_authored,
|
||||
reviewing_mrs,
|
||||
activity,
|
||||
since_last_check,
|
||||
};
|
||||
|
||||
// 10. Render
|
||||
// 9. Render
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,6 +318,7 @@ mod tests {
|
||||
all: false,
|
||||
user: user.map(String::from),
|
||||
fields: None,
|
||||
reset_cursor: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ use rusqlite::Connection;
|
||||
|
||||
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".
|
||||
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
||||
@@ -464,6 +470,223 @@ pub fn query_activity(
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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 {
|
||||
match s {
|
||||
"note" => ActivityEventType::Note,
|
||||
"mention_note" => ActivityEventType::Note,
|
||||
"status_change" => ActivityEventType::StatusChange,
|
||||
"label_change" => ActivityEventType::LabelChange,
|
||||
"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.
|
||||
/// `start_idx` is the 1-based parameter index for the first project ID.
|
||||
/// Returns empty string when no filter is needed (all projects).
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell
|
||||
|
||||
use super::types::{
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||
SinceLastCheck,
|
||||
};
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Render the complete human-mode dashboard.
|
||||
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_issues_section(&dashboard.open_issues, 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_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);
|
||||
|
||||
if show_issues {
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::core::time::ms_to_iso;
|
||||
|
||||
use super::types::{
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||
SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
|
||||
};
|
||||
|
||||
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
|
||||
@@ -43,6 +44,27 @@ pub fn print_me_json(
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -57,6 +79,8 @@ struct MeDataJson {
|
||||
username: String,
|
||||
since_iso: Option<String>,
|
||||
summary: SummaryJson,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
since_last_check: Option<SinceLastCheckJson>,
|
||||
open_issues: Vec<IssueJson>,
|
||||
open_mrs_authored: Vec<MrJson>,
|
||||
reviewing_mrs: Vec<MrJson>,
|
||||
@@ -69,6 +93,7 @@ impl MeDataJson {
|
||||
username: d.username.clone(),
|
||||
since_iso: d.since_ms.map(ms_to_iso),
|
||||
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_mrs_authored: d.open_mrs_authored.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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert `AttentionState` to its programmatic string representation.
|
||||
@@ -331,4 +417,12 @@ mod tests {
|
||||
assert!(!json.is_own);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,34 @@ pub struct MeActivityEvent {
|
||||
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.
|
||||
pub struct MeDashboard {
|
||||
pub username: String,
|
||||
@@ -95,4 +123,5 @@ pub struct MeDashboard {
|
||||
pub open_mrs_authored: Vec<MeMr>,
|
||||
pub reviewing_mrs: Vec<MeMr>,
|
||||
pub activity: Vec<MeActivityEvent>,
|
||||
pub since_last_check: Option<SinceLastCheck>,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ use crate::core::time::{format_full_datetime, ms_to_iso};
|
||||
|
||||
const RECENT_RUNS_LIMIT: usize = 10;
|
||||
|
||||
fn is_zero(value: &i64) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SyncRunInfo {
|
||||
pub id: i64,
|
||||
@@ -24,6 +28,15 @@ pub struct SyncRunInfo {
|
||||
pub total_items_processed: i64,
|
||||
pub total_errors: i64,
|
||||
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)]
|
||||
@@ -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>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"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
|
||||
ORDER BY started_at DESC
|
||||
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_errors: row.get::<_, Option<i64>>(8)?.unwrap_or(0),
|
||||
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();
|
||||
@@ -198,6 +221,23 @@ struct SyncRunJsonInfo {
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
@@ -237,6 +277,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
|
||||
total_errors: run.total_errors,
|
||||
error: run.error.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();
|
||||
|
||||
@@ -1095,6 +1095,10 @@ pub struct MeArgs {
|
||||
/// Select output fields (comma-separated, or 'minimal' preset)
|
||||
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||
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 {
|
||||
|
||||
152
src/core/cursor.rs
Normal file
152
src/core/cursor.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod backoff;
|
||||
pub mod config;
|
||||
#[cfg(unix)]
|
||||
pub mod cron;
|
||||
pub mod cursor;
|
||||
pub mod db;
|
||||
pub mod dependent_queue;
|
||||
pub mod error;
|
||||
|
||||
@@ -40,6 +40,20 @@ pub fn get_log_dir(config_override: Option<&str>) -> PathBuf {
|
||||
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 {
|
||||
if let Some(path) = config_override {
|
||||
return PathBuf::from(path);
|
||||
|
||||
19
src/main.rs
19
src/main.rs
@@ -2956,8 +2956,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
}
|
||||
},
|
||||
"me": {
|
||||
"description": "Personal work dashboard: open issues, authored/reviewing MRs, activity feed with computed attention states",
|
||||
"flags": ["--issues", "--mrs", "--activity", "--since <period>", "-p/--project <path>", "--all", "--user <username>", "--fields <list|minimal>"],
|
||||
"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>", "--reset-cursor"],
|
||||
"example": "lore --robot me",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
@@ -2965,6 +2965,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"username": "string",
|
||||
"since_iso": "string?",
|
||||
"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_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]",
|
||||
@@ -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",
|
||||
"section_flags": "If none of --issues/--mrs/--activity specified, all sections returned",
|
||||
"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": {
|
||||
@@ -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",
|
||||
"cron: Automated sync scheduling (Unix)",
|
||||
"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)."
|
||||
});
|
||||
@@ -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 --mr 10 -p group/project",
|
||||
"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"
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user