Compare commits
5 Commits
ec0aaaf77c
...
f9e7913232
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e7913232 | ||
|
|
6e487532aa | ||
|
|
7e9a23cc0f | ||
|
|
71d07c28d8 | ||
|
|
f4de6feaa2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Local config files
|
# Local config files
|
||||||
lore.config.json
|
lore.config.json
|
||||||
|
.liquid-mail.toml
|
||||||
|
|
||||||
# beads
|
# beads
|
||||||
.bv/
|
.bv/
|
||||||
|
|||||||
@@ -623,6 +623,12 @@ lore --robot generate-docs
|
|||||||
# Generate vector embeddings via Ollama
|
# Generate vector embeddings via Ollama
|
||||||
lore --robot embed
|
lore --robot embed
|
||||||
|
|
||||||
|
# Personal work dashboard
|
||||||
|
lore --robot me
|
||||||
|
lore --robot me --issues
|
||||||
|
lore --robot me --activity --since 7d
|
||||||
|
lore --robot me --fields minimal
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -642,6 +642,12 @@ lore --robot generate-docs
|
|||||||
# Generate vector embeddings via Ollama
|
# Generate vector embeddings via Ollama
|
||||||
lore --robot embed
|
lore --robot embed
|
||||||
|
|
||||||
|
# Personal work dashboard
|
||||||
|
lore --robot me
|
||||||
|
lore --robot me --issues
|
||||||
|
lore --robot me --activity --since 7d
|
||||||
|
lore --robot me --fields minimal
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|||||||
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -171,9 +171,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charmed-lipgloss"
|
name = "charmed-lipgloss"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
|
checksum = "a5986a4a6d84055da99e44a6c532fd412d636fe5c3fe17da105a7bf40287ccd1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"colored",
|
"colored",
|
||||||
@@ -183,6 +183,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"unicode-segmentation",
|
||||||
"unicode-width 0.1.14",
|
"unicode-width 0.1.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ clap_complete = "4"
|
|||||||
dialoguer = "0.12"
|
dialoguer = "0.12"
|
||||||
console = "0.16"
|
console = "0.16"
|
||||||
indicatif = "0.18"
|
indicatif = "0.18"
|
||||||
lipgloss = { package = "charmed-lipgloss", version = "0.1", default-features = false, features = ["native"] }
|
lipgloss = { package = "charmed-lipgloss", version = "0.2", default-features = false, features = ["native"] }
|
||||||
open = "5"
|
open = "5"
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id
|
|||||||
-- Immutable author identity column (GitLab numeric user ID)
|
-- Immutable author identity column (GitLab numeric user ID)
|
||||||
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (22, strftime('%s', 'now') * 1000, '022_notes_query_index');
|
||||||
|
|||||||
@@ -151,3 +151,6 @@ END;
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS _doc_labels_backup;
|
DROP TABLE IF EXISTS _doc_labels_backup;
|
||||||
DROP TABLE IF EXISTS _doc_paths_backup;
|
DROP TABLE IF EXISTS _doc_paths_backup;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (24, strftime('%s', 'now') * 1000, '024_note_documents');
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ FROM notes n
|
|||||||
LEFT JOIN documents d ON d.source_type = 'note' AND d.source_id = n.id
|
LEFT JOIN documents d ON d.source_type = 'note' AND d.source_id = n.id
|
||||||
WHERE n.is_system = 0 AND d.id IS NULL
|
WHERE n.is_system = 0 AND d.id IS NULL
|
||||||
ON CONFLICT(source_type, source_id) DO NOTHING;
|
ON CONFLICT(source_type, source_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (25, strftime('%s', 'now') * 1000, '025_note_dirty_backfill');
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
|||||||
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
ON notes(position_old_path, project_id, created_at)
|
ON notes(position_old_path, project_id, created_at)
|
||||||
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (26, strftime('%s', 'now') * 1000, '026_scoring_indexes');
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ CREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started
|
|||||||
ON sync_runs(mode, started_at DESC);
|
ON sync_runs(mode, started_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started
|
CREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started
|
||||||
ON sync_runs(status, phase, started_at DESC);
|
ON sync_runs(status, phase, started_at DESC);
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (27, strftime('%s', 'now') * 1000, '027_surgical_sync_runs');
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ fn insert_project(conn: &Connection, id: i64, path: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
|
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
|
||||||
insert_issue_with_state(conn, id, project_id, iid, author, "opened");
|
insert_issue_with_status(
|
||||||
|
conn,
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
iid,
|
||||||
|
author,
|
||||||
|
"opened",
|
||||||
|
Some("In Progress"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_issue_with_state(
|
fn insert_issue_with_state(
|
||||||
@@ -38,11 +46,30 @@ fn insert_issue_with_state(
|
|||||||
iid: i64,
|
iid: i64,
|
||||||
author: &str,
|
author: &str,
|
||||||
state: &str,
|
state: &str,
|
||||||
|
) {
|
||||||
|
// For closed issues, don't set status_name (they won't appear in dashboard anyway)
|
||||||
|
let status_name = if state == "opened" {
|
||||||
|
Some("In Progress")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
insert_issue_with_status(conn, id, project_id, iid, author, state, status_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn insert_issue_with_status(
|
||||||
|
conn: &Connection,
|
||||||
|
id: i64,
|
||||||
|
project_id: i64,
|
||||||
|
iid: i64,
|
||||||
|
author: &str,
|
||||||
|
state: &str,
|
||||||
|
status_name: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let ts = now_ms();
|
let ts = now_ms();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, status_name, author_username, created_at, updated_at, last_seen_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
id,
|
id,
|
||||||
id * 10,
|
id * 10,
|
||||||
@@ -50,6 +77,7 @@ fn insert_issue_with_state(
|
|||||||
iid,
|
iid,
|
||||||
format!("Issue {iid}"),
|
format!("Issue {iid}"),
|
||||||
state,
|
state,
|
||||||
|
status_name,
|
||||||
author,
|
author,
|
||||||
ts,
|
ts,
|
||||||
ts,
|
ts,
|
||||||
@@ -552,7 +580,9 @@ fn activity_since_filter() {
|
|||||||
let since = now_ms() - 50_000;
|
let since = now_ms() - 50_000;
|
||||||
let results = query_activity(&conn, "alice", &[], since).unwrap();
|
let results = query_activity(&conn, "alice", &[], since).unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].body_preview, Some("new comment".to_string()));
|
// Notes no longer duplicate body into body_preview (summary carries the content)
|
||||||
|
assert_eq!(results[0].body_preview, None);
|
||||||
|
assert_eq!(results[0].summary, "new comment");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use crate::core::time::parse_since;
|
|||||||
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
|
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
|
||||||
use self::types::{AttentionState, MeDashboard, MeSummary};
|
use self::types::{AttentionState, MeDashboard, MeSummary};
|
||||||
|
|
||||||
/// Default activity lookback: 30 days in milliseconds (AC-2.3).
|
/// Default activity lookback: 1 day in milliseconds.
|
||||||
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 30;
|
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
||||||
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
|
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/// Resolve the effective username from CLI flag or config.
|
/// Resolve the effective username from CLI flag or config.
|
||||||
@@ -96,7 +96,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
|||||||
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 30d for activity feed, AC-2.3)
|
// 5. 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!(
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ pub fn query_open_issues(
|
|||||||
)
|
)
|
||||||
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
|
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
|
||||||
CASE
|
CASE
|
||||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
||||||
THEN 'needs_attention'
|
|
||||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||||
THEN 'stale'
|
THEN 'stale'
|
||||||
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||||
|
THEN 'needs_attention'
|
||||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||||
THEN 'awaiting_response'
|
THEN 'awaiting_response'
|
||||||
ELSE 'not_started'
|
ELSE 'not_started'
|
||||||
@@ -52,17 +52,20 @@ pub fn query_open_issues(
|
|||||||
LEFT JOIN note_ts nt ON nt.issue_id = i.id
|
LEFT JOIN note_ts nt ON nt.issue_id = i.id
|
||||||
WHERE ia.username = ?1
|
WHERE ia.username = ?1
|
||||||
AND i.state = 'opened'
|
AND i.state = 'opened'
|
||||||
|
AND (i.status_name COLLATE NOCASE IN ('In Progress', 'In Review') OR i.status_name IS NULL)
|
||||||
{project_clause}
|
{project_clause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||||
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
|
||||||
THEN 0
|
THEN 0
|
||||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
|
||||||
THEN 1
|
THEN 1
|
||||||
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||||
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
|
||||||
|
THEN 2
|
||||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||||
THEN 3
|
THEN 3
|
||||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
|
||||||
THEN 2
|
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END,
|
END,
|
||||||
i.updated_at DESC",
|
i.updated_at DESC",
|
||||||
@@ -119,10 +122,10 @@ pub fn query_authored_mrs(
|
|||||||
WHEN m.draft = 1 AND NOT EXISTS (
|
WHEN m.draft = 1 AND NOT EXISTS (
|
||||||
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
|
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
|
||||||
) THEN 'not_ready'
|
) THEN 'not_ready'
|
||||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
||||||
THEN 'needs_attention'
|
|
||||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||||
THEN 'stale'
|
THEN 'stale'
|
||||||
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||||
|
THEN 'needs_attention'
|
||||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||||
THEN 'awaiting_response'
|
THEN 'awaiting_response'
|
||||||
ELSE 'not_started'
|
ELSE 'not_started'
|
||||||
@@ -136,10 +139,12 @@ pub fn query_authored_mrs(
|
|||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
|
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
|
||||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||||
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
|
||||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
||||||
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||||
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
|
||||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
||||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
|
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END,
|
END,
|
||||||
m.updated_at DESC",
|
m.updated_at DESC",
|
||||||
@@ -196,10 +201,10 @@ pub fn query_reviewing_mrs(
|
|||||||
m.author_username, m.updated_at, m.web_url,
|
m.author_username, m.updated_at, m.web_url,
|
||||||
CASE
|
CASE
|
||||||
-- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
|
-- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
|
||||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
|
||||||
THEN 'needs_attention'
|
|
||||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||||
THEN 'stale'
|
THEN 'stale'
|
||||||
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||||
|
THEN 'needs_attention'
|
||||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||||
THEN 'awaiting_response'
|
THEN 'awaiting_response'
|
||||||
ELSE 'not_started'
|
ELSE 'not_started'
|
||||||
@@ -213,10 +218,12 @@ pub fn query_reviewing_mrs(
|
|||||||
{project_clause}
|
{project_clause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
|
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||||
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
|
||||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
||||||
|
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||||
|
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
|
||||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
||||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
|
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END,
|
END,
|
||||||
m.updated_at DESC",
|
m.updated_at DESC",
|
||||||
@@ -264,13 +271,19 @@ pub fn query_activity(
|
|||||||
let project_clause = build_project_clause_at("p.id", project_ids, 3);
|
let project_clause = build_project_clause_at("p.id", project_ids, 3);
|
||||||
|
|
||||||
// Build the "my items" subquery fragments for issue/MR association checks.
|
// Build the "my items" subquery fragments for issue/MR association checks.
|
||||||
// These ensure we only see activity on items CURRENTLY associated with the user (AC-3.6).
|
// These ensure we only see activity on items CURRENTLY associated with the user
|
||||||
|
// AND currently open (AC-3.6). Without the state filter, activity would include
|
||||||
|
// events on closed/merged items that don't appear in the dashboard lists.
|
||||||
let my_issue_check = "EXISTS (
|
let my_issue_check = "EXISTS (
|
||||||
SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
|
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 = "(
|
let my_mr_check = "(
|
||||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
|
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 WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
|
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
||||||
|
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id
|
||||||
|
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
|
||||||
)";
|
)";
|
||||||
|
|
||||||
// Source 1: Human comments on my items
|
// Source 1: Human comments on my items
|
||||||
@@ -282,7 +295,7 @@ pub fn query_activity(
|
|||||||
n.author_username,
|
n.author_username,
|
||||||
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
|
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
|
||||||
SUBSTR(n.body, 1, 200),
|
SUBSTR(n.body, 1, 200),
|
||||||
SUBSTR(n.body, 1, 200)
|
NULL
|
||||||
FROM notes n
|
FROM notes n
|
||||||
JOIN discussions d ON n.discussion_id = d.id
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
JOIN projects p ON d.project_id = p.id
|
JOIN projects p ON d.project_id = p.id
|
||||||
@@ -419,7 +432,8 @@ pub fn query_activity(
|
|||||||
UNION ALL {label_sql}
|
UNION ALL {label_sql}
|
||||||
UNION ALL {milestone_sql}
|
UNION ALL {milestone_sql}
|
||||||
UNION ALL {assign_sql}
|
UNION ALL {assign_sql}
|
||||||
ORDER BY 1 DESC"
|
ORDER BY 1 DESC
|
||||||
|
LIMIT 100"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
use crate::cli::render::{self, GlyphMode, Icons, LoreRenderer, Theme};
|
use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Layout Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compute the title/summary column width for a section given its fixed overhead.
|
||||||
|
/// Returns a width clamped to [20, 80].
|
||||||
|
fn title_width(overhead: usize) -> usize {
|
||||||
|
render::terminal_width()
|
||||||
|
.saturating_sub(overhead)
|
||||||
|
.clamp(20, 80)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Glyph Mode Helper ──────────────────────────────────────────────────────
|
// ─── Glyph Mode Helper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Get the current glyph mode, defaulting to Unicode if renderer not initialized.
|
/// Get the current glyph mode, defaulting to Unicode if renderer not initialized.
|
||||||
@@ -61,28 +71,55 @@ fn styled_attention(state: &AttentionState) -> String {
|
|||||||
attention_style(state).render(icon)
|
attention_style(state).render(icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Merge Status Labels ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Convert GitLab's `detailed_merge_status` API values to human-friendly labels.
|
||||||
|
fn humanize_merge_status(status: &str) -> &str {
|
||||||
|
match status {
|
||||||
|
"not_approved" => "needs approval",
|
||||||
|
"requested_changes" => "changes requested",
|
||||||
|
"mergeable" => "ready to merge",
|
||||||
|
"not_open" => "not open",
|
||||||
|
"checking" => "checking",
|
||||||
|
"ci_must_pass" => "CI pending",
|
||||||
|
"ci_still_running" => "CI running",
|
||||||
|
"discussions_not_resolved" => "unresolved threads",
|
||||||
|
"draft_status" => "draft",
|
||||||
|
"need_rebase" => "needs rebase",
|
||||||
|
"conflict" | "has_conflicts" => "has conflicts",
|
||||||
|
"blocked_status" => "blocked",
|
||||||
|
"approvals_syncing" => "syncing approvals",
|
||||||
|
"jira_association_missing" => "missing Jira link",
|
||||||
|
"unchecked" => "unchecked",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Event Badges ────────────────────────────────────────────────────────────
|
// ─── Event Badges ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Render an activity event badge (colored).
|
/// Return the badge label text for an activity event type.
|
||||||
fn event_badge(event_type: &ActivityEventType) -> String {
|
fn activity_badge_label(event_type: &ActivityEventType) -> String {
|
||||||
let mode = glyph_mode();
|
match event_type {
|
||||||
let (label, style) = match event_type {
|
ActivityEventType::Note => "note",
|
||||||
ActivityEventType::Note => ("note", Theme::info()),
|
ActivityEventType::StatusChange => "status",
|
||||||
ActivityEventType::StatusChange => ("status", Theme::warning()),
|
ActivityEventType::LabelChange => "label",
|
||||||
ActivityEventType::LabelChange => ("label", Theme::accent()),
|
ActivityEventType::Assign | ActivityEventType::Unassign => "assign",
|
||||||
ActivityEventType::Assign | ActivityEventType::Unassign => ("assign", Theme::success()),
|
ActivityEventType::ReviewRequest => "review",
|
||||||
ActivityEventType::ReviewRequest => ("assign", Theme::success()),
|
ActivityEventType::MilestoneChange => "milestone",
|
||||||
ActivityEventType::MilestoneChange => ("milestone", accent_magenta()),
|
}
|
||||||
};
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
match mode {
|
/// Return the style for an activity event badge.
|
||||||
GlyphMode::Ascii => style.render(&format!("[{label}]")),
|
fn activity_badge_style(event_type: &ActivityEventType) -> lipgloss::Style {
|
||||||
_ => {
|
match event_type {
|
||||||
// For nerd/unicode, use colored bg with dark text where possible.
|
ActivityEventType::Note => Theme::info(),
|
||||||
// lipgloss background support is limited, so we use colored text as a
|
ActivityEventType::StatusChange => Theme::warning(),
|
||||||
// practical fallback that still provides the visual distinction.
|
ActivityEventType::LabelChange => Theme::accent(),
|
||||||
style.render(&format!(" {label} "))
|
ActivityEventType::Assign
|
||||||
}
|
| ActivityEventType::Unassign
|
||||||
|
| ActivityEventType::ReviewRequest => Theme::success(),
|
||||||
|
ActivityEventType::MilestoneChange => accent_magenta(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +132,15 @@ fn accent_magenta() -> lipgloss::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Very dark gray for system events (label, assign, status, milestone, review).
|
||||||
|
fn system_event_style() -> lipgloss::Style {
|
||||||
|
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
|
||||||
|
lipgloss::Style::new().foreground("#555555")
|
||||||
|
} else {
|
||||||
|
lipgloss::Style::new().faint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Summary Header ─────────────────────────────────────────────────────────
|
// ─── Summary Header ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Print the summary header with counts and attention legend (Task #14).
|
/// Print the summary header with counts and attention legend (Task #14).
|
||||||
@@ -108,7 +154,7 @@ pub fn print_summary_header(summary: &MeSummary, username: &str) {
|
|||||||
username,
|
username,
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
println!("{}", "\u{2500}".repeat(60));
|
println!("{}", "\u{2500}".repeat(render::terminal_width()));
|
||||||
|
|
||||||
// Counts line
|
// Counts line
|
||||||
let needs = if summary.needs_attention_count > 0 {
|
let needs = if summary.needs_attention_count > 0 {
|
||||||
@@ -182,7 +228,7 @@ pub fn print_issues_section(issues: &[MeIssue], single_project: bool) {
|
|||||||
" {} {} {}{} {}",
|
" {} {} {}{} {}",
|
||||||
attn,
|
attn,
|
||||||
Theme::issue_ref().render(&ref_str),
|
Theme::issue_ref().render(&ref_str),
|
||||||
render::truncate(&issue.title, 40),
|
render::truncate(&issue.title, title_width(43)),
|
||||||
Theme::dim().render(&status),
|
Theme::dim().render(&status),
|
||||||
Theme::dim().render(&time),
|
Theme::dim().render(&time),
|
||||||
);
|
);
|
||||||
@@ -224,7 +270,7 @@ pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
|
|||||||
.detailed_merge_status
|
.detailed_merge_status
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|s| !s.is_empty() && *s != "not_open")
|
.filter(|s| !s.is_empty() && *s != "not_open")
|
||||||
.map(|s| format!(" ({s})"))
|
.map(|s| format!(" ({})", humanize_merge_status(s)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let time = render::format_relative_time(mr.updated_at);
|
let time = render::format_relative_time(mr.updated_at);
|
||||||
|
|
||||||
@@ -233,7 +279,7 @@ pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
|
|||||||
" {} {} {}{}{} {}",
|
" {} {} {}{}{} {}",
|
||||||
attn,
|
attn,
|
||||||
Theme::mr_ref().render(&ref_str),
|
Theme::mr_ref().render(&ref_str),
|
||||||
render::truncate(&mr.title, 35),
|
render::truncate(&mr.title, title_width(48)),
|
||||||
draft,
|
draft,
|
||||||
Theme::dim().render(&merge_status),
|
Theme::dim().render(&merge_status),
|
||||||
Theme::dim().render(&time),
|
Theme::dim().render(&time),
|
||||||
@@ -282,7 +328,7 @@ pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) {
|
|||||||
" {} {} {}{}{} {}",
|
" {} {} {}{}{} {}",
|
||||||
attn,
|
attn,
|
||||||
Theme::mr_ref().render(&ref_str),
|
Theme::mr_ref().render(&ref_str),
|
||||||
render::truncate(&mr.title, 30),
|
render::truncate(&mr.title, title_width(50)),
|
||||||
author,
|
author,
|
||||||
draft,
|
draft,
|
||||||
Theme::dim().render(&time),
|
Theme::dim().render(&time),
|
||||||
@@ -313,68 +359,119 @@ pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool)
|
|||||||
render::section_divider(&format!("Activity ({})", events.len()))
|
render::section_divider(&format!("Activity ({})", events.len()))
|
||||||
);
|
);
|
||||||
|
|
||||||
for event in events {
|
// Columns: badge | ref | summary | actor | time
|
||||||
let badge = event_badge(&event.event_type);
|
// Table handles alignment, padding, and truncation automatically.
|
||||||
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
let summary_max = title_width(46);
|
||||||
let time = render::format_relative_time_compact(event.timestamp);
|
let mut table = Table::new()
|
||||||
|
.columns(5)
|
||||||
|
.indent(4)
|
||||||
|
.align(1, Align::Right)
|
||||||
|
.align(4, Align::Right)
|
||||||
|
.max_width(2, summary_max);
|
||||||
|
|
||||||
let actor_str = if event.is_own {
|
for event in events {
|
||||||
Theme::dim().render(&format!(
|
let badge_label = activity_badge_label(&event.event_type);
|
||||||
"{}(you)",
|
let badge_style = activity_badge_style(&event.event_type);
|
||||||
event
|
|
||||||
.actor
|
let ref_text = match event.entity_type.as_str() {
|
||||||
.as_deref()
|
"issue" => format!("#{}", event.entity_iid),
|
||||||
.map(|a| format!("@{a} "))
|
"mr" => format!("!{}", event.entity_iid),
|
||||||
.unwrap_or_default()
|
_ => format!("{}:{}", event.entity_type, event.entity_iid),
|
||||||
))
|
};
|
||||||
|
let is_system = !matches!(event.event_type, ActivityEventType::Note);
|
||||||
|
// System events → very dark gray; own notes → standard dim; else → full color.
|
||||||
|
let subdued = is_system || event.is_own;
|
||||||
|
let subdued_style = || {
|
||||||
|
if is_system {
|
||||||
|
system_event_style()
|
||||||
|
} else {
|
||||||
|
Theme::dim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let badge_style_final = if subdued {
|
||||||
|
subdued_style()
|
||||||
|
} else {
|
||||||
|
badge_style
|
||||||
|
};
|
||||||
|
|
||||||
|
let ref_style = if subdued {
|
||||||
|
Some(subdued_style())
|
||||||
|
} else {
|
||||||
|
match event.entity_type.as_str() {
|
||||||
|
"issue" => Some(Theme::issue_ref()),
|
||||||
|
"mr" => Some(Theme::mr_ref()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let clean_summary = event.summary.replace('\n', " ");
|
||||||
|
let summary_style: Option<lipgloss::Style> =
|
||||||
|
if subdued { Some(subdued_style()) } else { None };
|
||||||
|
|
||||||
|
let actor_text = if event.is_own {
|
||||||
|
event
|
||||||
|
.actor
|
||||||
|
.as_deref()
|
||||||
|
.map_or("(you)".to_string(), |a| format!("@{a} (you)"))
|
||||||
} else {
|
} else {
|
||||||
event
|
event
|
||||||
.actor
|
.actor
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|a| Theme::username().render(&format!("@{a}")))
|
.map_or(String::new(), |a| format!("@{a}"))
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
};
|
||||||
|
let actor_style = if subdued {
|
||||||
let summary = render::truncate(&event.summary, 40);
|
subdued_style()
|
||||||
|
|
||||||
// Dim own actions
|
|
||||||
let summary_styled = if event.is_own {
|
|
||||||
Theme::dim().render(&summary)
|
|
||||||
} else {
|
} else {
|
||||||
summary
|
Theme::username()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Line 1: badge, entity ref, summary, actor, time
|
let time = render::format_relative_time_compact(event.timestamp);
|
||||||
println!(
|
|
||||||
" {badge} {entity_ref:7} {summary_styled} {actor_str} {}",
|
|
||||||
Theme::dim().render(&time),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Line 2: project path (if multi-project) + body preview for notes
|
table.add_row(vec![
|
||||||
|
StyledCell::styled(badge_label, badge_style_final),
|
||||||
|
match ref_style {
|
||||||
|
Some(s) => StyledCell::styled(ref_text, s),
|
||||||
|
None => StyledCell::plain(ref_text),
|
||||||
|
},
|
||||||
|
match summary_style {
|
||||||
|
Some(s) => StyledCell::styled(clean_summary, s),
|
||||||
|
None => StyledCell::plain(clean_summary),
|
||||||
|
},
|
||||||
|
StyledCell::styled(actor_text, actor_style),
|
||||||
|
StyledCell::styled(time, Theme::dim()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render table rows and interleave per-event detail lines
|
||||||
|
let rendered = table.render();
|
||||||
|
for (line, event) in rendered.lines().zip(events.iter()) {
|
||||||
|
println!("{line}");
|
||||||
if !single_project {
|
if !single_project {
|
||||||
println!(" {}", Theme::dim().render(&event.project_path),);
|
println!(" {}", Theme::dim().render(&event.project_path));
|
||||||
}
|
}
|
||||||
if let Some(preview) = &event.body_preview
|
if let Some(preview) = &event.body_preview
|
||||||
&& !preview.is_empty()
|
&& !preview.is_empty()
|
||||||
{
|
{
|
||||||
let truncated = render::truncate(preview, 60);
|
let truncated = render::truncate(preview, 60);
|
||||||
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")),);
|
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an entity reference (#N for issues, !N for MRs).
|
/// Format an entity reference (#N for issues, !N for MRs), right-aligned to 6 chars.
|
||||||
|
#[cfg(test)]
|
||||||
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||||
match entity_type {
|
match entity_type {
|
||||||
"issue" => {
|
"issue" => {
|
||||||
let s = format!("#{iid}");
|
let s = format!("{:>6}", format!("#{iid}"));
|
||||||
Theme::issue_ref().render(&s)
|
Theme::issue_ref().render(&s)
|
||||||
}
|
}
|
||||||
"mr" => {
|
"mr" => {
|
||||||
let s = format!("!{iid}");
|
let s = format!("{:>6}", format!("!{iid}"));
|
||||||
Theme::mr_ref().render(&s)
|
Theme::mr_ref().render(&s)
|
||||||
}
|
}
|
||||||
_ => format!("{entity_type}:{iid}"),
|
_ => format!("{:>6}", format!("{entity_type}:{iid}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +543,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_badge_returns_nonempty_for_all_types() {
|
fn activity_badge_label_returns_nonempty_for_all_types() {
|
||||||
let types = [
|
let types = [
|
||||||
ActivityEventType::Note,
|
ActivityEventType::Note,
|
||||||
ActivityEventType::StatusChange,
|
ActivityEventType::StatusChange,
|
||||||
@@ -457,7 +554,7 @@ mod tests {
|
|||||||
ActivityEventType::MilestoneChange,
|
ActivityEventType::MilestoneChange,
|
||||||
];
|
];
|
||||||
for t in &types {
|
for t in &types {
|
||||||
assert!(!event_badge(t).is_empty(), "empty for {t:?}");
|
assert!(!activity_badge_label(t).is_empty(), "empty for {t:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,6 +532,43 @@ pub fn format_datetime(ms: i64) -> String {
|
|||||||
.unwrap_or_else(|| "unknown".to_string())
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect terminal width. Checks `COLUMNS` env, then stderr ioctl, falls back to 80.
|
||||||
|
pub fn terminal_width() -> usize {
|
||||||
|
// 1. Explicit COLUMNS env (set by some shells, resized terminals)
|
||||||
|
if let Ok(val) = std::env::var("COLUMNS")
|
||||||
|
&& let Ok(w) = val.parse::<usize>()
|
||||||
|
&& w > 0
|
||||||
|
{
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ioctl on stderr (works even when stdout is piped)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
#[repr(C)]
|
||||||
|
struct winsize {
|
||||||
|
ws_row: libc::c_ushort,
|
||||||
|
ws_col: libc::c_ushort,
|
||||||
|
ws_xpixel: libc::c_ushort,
|
||||||
|
ws_ypixel: libc::c_ushort,
|
||||||
|
}
|
||||||
|
let mut ws = MaybeUninit::<winsize>::uninit();
|
||||||
|
// SAFETY: ioctl with TIOCGWINSZ writes into the winsize struct.
|
||||||
|
// stderr (fd 2) is used because stdout may be piped.
|
||||||
|
if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, ws.as_mut_ptr()) } == 0 {
|
||||||
|
let ws = unsafe { ws.assume_init() };
|
||||||
|
let w = ws.ws_col as usize;
|
||||||
|
if w > 0 {
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
80
|
||||||
|
}
|
||||||
|
|
||||||
/// Truncate a string to `max` characters, appending "..." if truncated.
|
/// Truncate a string to `max` characters, appending "..." if truncated.
|
||||||
pub fn truncate(s: &str, max: usize) -> String {
|
pub fn truncate(s: &str, max: usize) -> String {
|
||||||
if max < 4 {
|
if max < 4 {
|
||||||
@@ -545,6 +582,17 @@ pub fn truncate(s: &str, max: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Truncate and right-pad to exactly `width` visible characters.
|
||||||
|
pub fn truncate_pad(s: &str, width: usize) -> String {
|
||||||
|
let t = truncate(s, width);
|
||||||
|
let count = t.chars().count();
|
||||||
|
if count < width {
|
||||||
|
format!("{t}{}", " ".repeat(width - count))
|
||||||
|
} else {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Word-wrap text to `width`, prepending `indent` to continuation lines.
|
/// Word-wrap text to `width`, prepending `indent` to continuation lines.
|
||||||
/// Returns a single string with embedded newlines.
|
/// Returns a single string with embedded newlines.
|
||||||
pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String {
|
pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String {
|
||||||
@@ -603,7 +651,10 @@ pub fn wrap_lines(text: &str, width: usize) -> Vec<String> {
|
|||||||
|
|
||||||
/// Render a section divider: `── Title ──────────────────────`
|
/// Render a section divider: `── Title ──────────────────────`
|
||||||
pub fn section_divider(title: &str) -> String {
|
pub fn section_divider(title: &str) -> String {
|
||||||
let rule_len = 40_usize.saturating_sub(title.len() + 4);
|
// prefix: 2 indent + 2 box-drawing + 1 space = 5
|
||||||
|
// suffix: 1 space + trailing box-drawing
|
||||||
|
let used = 5 + title.len() + 1;
|
||||||
|
let rule_len = terminal_width().saturating_sub(used);
|
||||||
format!(
|
format!(
|
||||||
"\n {} {} {}",
|
"\n {} {} {}",
|
||||||
Theme::dim().render("\u{2500}\u{2500}"),
|
Theme::dim().render("\u{2500}\u{2500}"),
|
||||||
@@ -734,6 +785,8 @@ pub struct Table {
|
|||||||
rows: Vec<Vec<StyledCell>>,
|
rows: Vec<Vec<StyledCell>>,
|
||||||
alignments: Vec<Align>,
|
alignments: Vec<Align>,
|
||||||
max_widths: Vec<Option<usize>>,
|
max_widths: Vec<Option<usize>>,
|
||||||
|
col_count: usize,
|
||||||
|
indent: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Table {
|
impl Table {
|
||||||
@@ -744,9 +797,23 @@ impl Table {
|
|||||||
/// Set column headers.
|
/// Set column headers.
|
||||||
pub fn headers(mut self, h: &[&str]) -> Self {
|
pub fn headers(mut self, h: &[&str]) -> Self {
|
||||||
self.headers = h.iter().map(|s| (*s).to_string()).collect();
|
self.headers = h.iter().map(|s| (*s).to_string()).collect();
|
||||||
// Initialize alignments and max_widths to match column count
|
self.col_count = self.headers.len();
|
||||||
self.alignments.resize(self.headers.len(), Align::Left);
|
self.alignments.resize(self.col_count, Align::Left);
|
||||||
self.max_widths.resize(self.headers.len(), None);
|
self.max_widths.resize(self.col_count, None);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set column count without headers (headerless table).
|
||||||
|
pub fn columns(mut self, n: usize) -> Self {
|
||||||
|
self.col_count = n;
|
||||||
|
self.alignments.resize(n, Align::Left);
|
||||||
|
self.max_widths.resize(n, None);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set indent (number of spaces) prepended to each row.
|
||||||
|
pub fn indent(mut self, spaces: usize) -> Self {
|
||||||
|
self.indent = spaces;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,15 +840,20 @@ impl Table {
|
|||||||
|
|
||||||
/// Render the table to a string.
|
/// Render the table to a string.
|
||||||
pub fn render(&self) -> String {
|
pub fn render(&self) -> String {
|
||||||
if self.headers.is_empty() {
|
let col_count = self.col_count;
|
||||||
|
if col_count == 0 {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let col_count = self.headers.len();
|
|
||||||
let gap = " "; // 2-space gap between columns
|
let gap = " "; // 2-space gap between columns
|
||||||
|
let indent_str = " ".repeat(self.indent);
|
||||||
|
|
||||||
// Compute column widths from content
|
// Compute column widths from headers (if any) and all row cells
|
||||||
let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
|
let mut widths: Vec<usize> = if self.headers.is_empty() {
|
||||||
|
vec![0; col_count]
|
||||||
|
} else {
|
||||||
|
self.headers.iter().map(|h| h.chars().count()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
for row in &self.rows {
|
for row in &self.rows {
|
||||||
for (i, cell) in row.iter().enumerate() {
|
for (i, cell) in row.iter().enumerate() {
|
||||||
@@ -802,29 +874,32 @@ impl Table {
|
|||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|
||||||
// Header row (bold)
|
// Header row + separator (only when headers are set)
|
||||||
let header_parts: Vec<String> = self
|
if !self.headers.is_empty() {
|
||||||
.headers
|
let header_parts: Vec<String> = self
|
||||||
.iter()
|
.headers
|
||||||
.enumerate()
|
.iter()
|
||||||
.map(|(i, h)| {
|
.enumerate()
|
||||||
let w = widths.get(i).copied().unwrap_or(0);
|
.map(|(i, h)| {
|
||||||
let text = truncate(h, w);
|
let w = widths.get(i).copied().unwrap_or(0);
|
||||||
pad_cell(
|
let text = truncate(h, w);
|
||||||
&text,
|
pad_cell(
|
||||||
w,
|
&text,
|
||||||
self.alignments.get(i).copied().unwrap_or(Align::Left),
|
w,
|
||||||
)
|
self.alignments.get(i).copied().unwrap_or(Align::Left),
|
||||||
})
|
)
|
||||||
.collect();
|
})
|
||||||
out.push_str(&Theme::header().render(&header_parts.join(gap)));
|
.collect();
|
||||||
out.push('\n');
|
out.push_str(&indent_str);
|
||||||
|
out.push_str(&Theme::header().render(&header_parts.join(gap)));
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
// Separator
|
let total_width: usize =
|
||||||
let total_width: usize =
|
widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
|
||||||
widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
|
out.push_str(&indent_str);
|
||||||
out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
|
out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
for row in &self.rows {
|
for row in &self.rows {
|
||||||
@@ -856,6 +931,7 @@ impl Table {
|
|||||||
parts.push(" ".repeat(w));
|
parts.push(" ".repeat(w));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.push_str(&indent_str);
|
||||||
out.push_str(&parts.join(gap));
|
out.push_str(&parts.join(gap));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,13 @@ impl LoreError {
|
|||||||
Self::DatabaseLocked { .. } => ErrorCode::DatabaseLocked,
|
Self::DatabaseLocked { .. } => ErrorCode::DatabaseLocked,
|
||||||
Self::MigrationFailed { .. } => ErrorCode::MigrationFailed,
|
Self::MigrationFailed { .. } => ErrorCode::MigrationFailed,
|
||||||
Self::TokenNotSet { .. } => ErrorCode::TokenNotSet,
|
Self::TokenNotSet { .. } => ErrorCode::TokenNotSet,
|
||||||
Self::Database(_) => ErrorCode::DatabaseError,
|
Self::Database(e) => {
|
||||||
|
if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) {
|
||||||
|
ErrorCode::DatabaseLocked
|
||||||
|
} else {
|
||||||
|
ErrorCode::DatabaseError
|
||||||
|
}
|
||||||
|
}
|
||||||
Self::Http(_) => ErrorCode::GitLabNetworkError,
|
Self::Http(_) => ErrorCode::GitLabNetworkError,
|
||||||
Self::Json(_) => ErrorCode::InternalError,
|
Self::Json(_) => ErrorCode::InternalError,
|
||||||
Self::Io(_) => ErrorCode::IoError,
|
Self::Io(_) => ErrorCode::IoError,
|
||||||
@@ -218,14 +224,20 @@ impl LoreError {
|
|||||||
"Wait for other sync to complete or use --force.\n\n Example:\n lore ingest --force\n lore ingest issues --force",
|
"Wait for other sync to complete or use --force.\n\n Example:\n lore ingest --force\n lore ingest issues --force",
|
||||||
),
|
),
|
||||||
Self::MigrationFailed { .. } => Some(
|
Self::MigrationFailed { .. } => Some(
|
||||||
"Check database file permissions or reset with 'lore reset'.\n\n Example:\n lore migrate\n lore reset --yes",
|
"Check database file permissions and try again.\n\n Example:\n lore migrate\n lore doctor",
|
||||||
),
|
),
|
||||||
Self::TokenNotSet { .. } => Some(
|
Self::TokenNotSet { .. } => Some(
|
||||||
"Set your token:\n\n lore token set\n\n Or export to your shell:\n\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n Your token needs the read_api scope.",
|
"Set your token:\n\n lore token set\n\n Or export to your shell:\n\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n Your token needs the read_api scope.",
|
||||||
),
|
),
|
||||||
Self::Database(_) => Some(
|
Self::Database(e) => {
|
||||||
"Check database file permissions or reset with 'lore reset'.\n\n Example:\n lore doctor\n lore reset --yes",
|
if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) {
|
||||||
),
|
Some(
|
||||||
|
"Another process has the database locked. Wait a moment and retry.\n\n Common causes:\n - A cron sync is running (lore cron status)\n - Another lore command is active",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Some("Check database file permissions.\n\n Example:\n lore doctor")
|
||||||
|
}
|
||||||
|
}
|
||||||
Self::Http(_) => Some("Check network connection"),
|
Self::Http(_) => Some("Check network connection"),
|
||||||
Self::NotFound(_) => {
|
Self::NotFound(_) => {
|
||||||
Some("Verify the entity exists.\n\n Example:\n lore issues\n lore mrs")
|
Some("Verify the entity exists.\n\n Example:\n lore issues\n lore mrs")
|
||||||
@@ -267,6 +279,11 @@ impl LoreError {
|
|||||||
Self::OllamaUnavailable { .. } => vec!["ollama serve"],
|
Self::OllamaUnavailable { .. } => vec!["ollama serve"],
|
||||||
Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"],
|
Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"],
|
||||||
Self::DatabaseLocked { .. } => vec!["lore ingest --force"],
|
Self::DatabaseLocked { .. } => vec!["lore ingest --force"],
|
||||||
|
Self::Database(e)
|
||||||
|
if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) =>
|
||||||
|
{
|
||||||
|
vec!["lore cron status"]
|
||||||
|
}
|
||||||
Self::EmbeddingsNotBuilt => vec!["lore embed"],
|
Self::EmbeddingsNotBuilt => vec!["lore embed"],
|
||||||
Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"],
|
Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"],
|
||||||
Self::MigrationFailed { .. } => vec!["lore migrate"],
|
Self::MigrationFailed { .. } => vec!["lore migrate"],
|
||||||
|
|||||||
32
src/main.rs
32
src/main.rs
@@ -2955,6 +2955,35 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
|||||||
"meta": {"elapsed_ms": "int"}
|
"meta": {"elapsed_ms": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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>"],
|
||||||
|
"example": "lore --robot me",
|
||||||
|
"response_schema": {
|
||||||
|
"ok": "bool",
|
||||||
|
"data": {
|
||||||
|
"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"},
|
||||||
|
"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]",
|
||||||
|
"activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]"
|
||||||
|
},
|
||||||
|
"meta": {"elapsed_ms": "int"}
|
||||||
|
},
|
||||||
|
"fields_presets": {
|
||||||
|
"me_items_minimal": ["iid", "title", "attention_state", "updated_at_iso"],
|
||||||
|
"me_activity_minimal": ["timestamp_iso", "event_type", "entity_iid", "actor"]
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"attention_states": "needs_attention | not_started | awaiting_response | stale | not_ready",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
"robot-docs": {
|
"robot-docs": {
|
||||||
"description": "This command (agent self-discovery manifest)",
|
"description": "This command (agent self-discovery manifest)",
|
||||||
"flags": ["--brief"],
|
"flags": ["--brief"],
|
||||||
@@ -2983,7 +3012,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
|||||||
"count: Entity counts with state breakdowns",
|
"count: Entity counts with state breakdowns",
|
||||||
"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"
|
||||||
],
|
],
|
||||||
"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)."
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user