4 Commits

Author SHA1 Message Date
teernisse
f4de6feaa2 chore: gitignore .liquid-mail.toml and remove from tracking
The file contains a Honcho API key that should not be in version control.
Added to .gitignore and untracked; the file remains on disk for local use.
2026-02-20 14:54:10 -05:00
teernisse
ec0aaaf77c chore: update beads tracker state
Sync beads issue database to JSONL for version control tracking.
2026-02-20 14:31:57 -05:00
teernisse
9c1a9bfe5d feat(me): add lore me personal work dashboard command
Implement a personal work dashboard that shows everything relevant to the
configured GitLab user: open issues assigned to them, MRs they authored,
MRs they are reviewing, and a chronological activity feed.

Design decisions:
- Attention state computed from GitLab interaction data (comments, reviews)
  with no local state tracking -- purely derived from existing synced data
- Username resolution: --user flag > config.gitlab.username > actionable error
- Project scoping: --project (fuzzy) | --all | default_project | all
- Section filtering: --issues, --mrs, --activity (combinable, default = all)
- Activity feed controlled by --since (default 30d); work item sections
  always show all open items regardless of --since

Architecture (src/cli/commands/me/):
- types.rs: MeDashboard, MeSummary, AttentionState data types
- queries.rs: 4 SQL queries (open_issues, authored_mrs, reviewing_mrs,
  activity) using existing issue_assignees, mr_reviewers, notes tables
- render_human.rs: colored terminal output with attention state indicators
- render_robot.rs: {ok, data, meta} JSON envelope with field selection
- mod.rs: orchestration (resolve_username, resolve_project_scope, run_me)
- me_tests.rs: comprehensive unit tests covering all query paths

Config additions:
- New optional gitlab.username field in config.json
- Tests for config with/without username
- Existing test configs updated with username: None

CLI wiring:
- MeArgs struct with section filter, since, project, all, user, fields flags
- Autocorrect support for me command flags
- LoreRenderer::try_get() for safe renderer access in me module
- Robot mode field selection presets (me_items, me_activity)
- handle_me() in main.rs command dispatch

Also fixes duplicate assertions in surgical sync tests (removed 6
duplicate assert! lines that were copy-paste artifacts).

Spec: docs/lore-me-spec.md
2026-02-20 14:31:57 -05:00
teernisse
a5c2589c7d docs: migrate agent coordination from MCP Agent Mail to Liquid Mail
Replace all MCP Agent Mail references with Liquid Mail in AGENTS.md and
CLAUDE.md. The old system used file reservations and MCP-based messaging
with inbox/outbox/thread semantics. Liquid Mail provides a simpler
post-based shared log with topic-scoped messages, decision conflict
detection, and polling via the liquid-mail CLI.

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

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
bd-2fc
bd-1tv8

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ yarn-error.log*
# Local config files
lore.config.json
.liquid-mail.toml
# beads
.bv/

View File

@@ -127,66 +127,17 @@ Prefer deterministic lab-runtime tests for concurrency-sensitive behavior.
---
## MCP Agent Mail — Multi-Agent Coordination
A mail-like layer that lets coding agents coordinate asynchronously via MCP tools and resources. Provides identities, inbox/outbox, searchable threads, and advisory file reservations with human-auditable artifacts in Git.
### Why It's Useful
- **Prevents conflicts:** Explicit file reservations (leases) for files/globs
- **Token-efficient:** Messages stored in per-project archive, not in context
- **Quick reads:** `resource://inbox/...`, `resource://thread/...`
### Same Repository Workflow
1. **Register identity:**
```
ensure_project(project_key=<abs-path>)
register_agent(project_key, program, model)
```
2. **Reserve files before editing:**
```
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true)
```
3. **Communicate with threads:**
```
send_message(..., thread_id="FEAT-123")
fetch_inbox(project_key, agent_name)
acknowledge_message(project_key, agent_name, message_id)
```
4. **Quick reads:**
```
resource://inbox/{Agent}?project=<abs-path>&limit=20
resource://thread/{id}?project=<abs-path>&include_bodies=true
```
### Macros vs Granular Tools
- **Prefer macros for speed:** `macro_start_session`, `macro_prepare_thread`, `macro_file_reservation_cycle`, `macro_contact_handshake`
- **Use granular tools for control:** `register_agent`, `file_reservation_paths`, `send_message`, `fetch_inbox`, `acknowledge_message`
### Common Pitfalls
- `"from_agent not registered"`: Always `register_agent` in the correct `project_key` first
- `"FILE_RESERVATION_CONFLICT"`: Adjust patterns, wait for expiry, or use non-exclusive reservation
- **Auth errors:** If JWT+JWKS enabled, include bearer token with matching `kid`
---
## Beads (br) — Dependency-Aware Issue Tracking
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements MCP Agent Mail's messaging and file reservations.
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements Liquid Mail's shared log for progress, decisions, and cross-session context.
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
### Conventions
- **Single source of truth:** Beads for task status/priority/dependencies; Agent Mail for conversation and audit
- **Shared identifiers:** Use Beads issue ID (e.g., `br-123`) as Mail `thread_id` and prefix subjects with `[br-123]`
- **Reservations:** When starting a task, call `file_reservation_paths()` with the issue ID in `reason`
- **Single source of truth:** Beads for task status/priority/dependencies; Liquid Mail for conversation/decisions
- **Shared identifiers:** Include the Beads issue ID in posts (e.g., `[br-123] Topic validation rules`)
- **Decisions before action:** Post `DECISION:` messages before risky changes, not after
### Typical Agent Flow
@@ -195,35 +146,34 @@ Beads provides a lightweight, dependency-aware issue database and CLI (`br` / be
br ready --json # Choose highest priority, no blockers
```
2. **Reserve edit surface (Mail):**
```
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true, reason="br-123")
2. **Check context (Liquid Mail):**
```bash
liquid-mail notify # See what changed since last session
liquid-mail query "br-123" # Find prior discussion on this issue
```
3. **Announce start (Mail):**
```
send_message(..., thread_id="br-123", subject="[br-123] Start: <title>", ack_required=true)
3. **Work and log progress:**
```bash
liquid-mail post --topic <workstream> "[br-123] START: <description>"
liquid-mail post "[br-123] FINDING: <what you discovered>"
liquid-mail post --decision "[br-123] DECISION: <what you decided and why>"
```
4. **Work and update:** Reply in-thread with progress
5. **Complete and release:**
4. **Complete (Beads is authority):**
```bash
br close br-123 --reason "Completed"
liquid-mail post "[br-123] Completed: <summary with commit ref>"
```
```
release_file_reservations(project_key, agent_name, paths=["src/**"])
```
Final Mail reply: `[br-123] Completed` with summary
### Mapping Cheat Sheet
| Concept | Value |
|---------|-------|
| Mail `thread_id` | `br-###` |
| Mail subject | `[br-###] ...` |
| File reservation `reason` | `br-###` |
| Commit messages | Include `br-###` for traceability |
| Concept | In Beads | In Liquid Mail |
|---------|----------|----------------|
| Work item | `br-###` (issue ID) | Include `[br-###]` in posts |
| Workstream | — | `--topic auth-system` |
| Subject prefix | — | `[br-###] ...` |
| Commit message | Include `br-###` | — |
| Status | `br update --status` | Post progress messages |
---
@@ -231,7 +181,7 @@ Beads provides a lightweight, dependency-aware issue database and CLI (`br` / be
bv is a graph-aware triage engine for Beads projects (`.beads/beads.jsonl`). It computes PageRank, betweenness, critical path, cycles, HITS, eigenvector, and k-core metrics deterministically.
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (messaging, work claiming, file reservations), use MCP Agent Mail.
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (progress logging, decisions, cross-session context), use Liquid Mail.
**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**

248
CLAUDE.md
View File

@@ -127,66 +127,19 @@ Prefer deterministic lab-runtime tests for concurrency-sensitive behavior.
---
## MCP Agent Mail — Multi-Agent Coordination
A mail-like layer that lets coding agents coordinate asynchronously via MCP tools and resources. Provides identities, inbox/outbox, searchable threads, and advisory file reservations with human-auditable artifacts in Git.
### Why It's Useful
- **Prevents conflicts:** Explicit file reservations (leases) for files/globs
- **Token-efficient:** Messages stored in per-project archive, not in context
- **Quick reads:** `resource://inbox/...`, `resource://thread/...`
### Same Repository Workflow
1. **Register identity:**
```
ensure_project(project_key=<abs-path>)
register_agent(project_key, program, model)
```
2. **Reserve files before editing:**
```
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true)
```
3. **Communicate with threads:**
```
send_message(..., thread_id="FEAT-123")
fetch_inbox(project_key, agent_name)
acknowledge_message(project_key, agent_name, message_id)
```
4. **Quick reads:**
```
resource://inbox/{Agent}?project=<abs-path>&limit=20
resource://thread/{id}?project=<abs-path>&include_bodies=true
```
### Macros vs Granular Tools
- **Prefer macros for speed:** `macro_start_session`, `macro_prepare_thread`, `macro_file_reservation_cycle`, `macro_contact_handshake`
- **Use granular tools for control:** `register_agent`, `file_reservation_paths`, `send_message`, `fetch_inbox`, `acknowledge_message`
### Common Pitfalls
- `"from_agent not registered"`: Always `register_agent` in the correct `project_key` first
- `"FILE_RESERVATION_CONFLICT"`: Adjust patterns, wait for expiry, or use non-exclusive reservation
- **Auth errors:** If JWT+JWKS enabled, include bearer token with matching `kid`
---
## Beads (br) — Dependency-Aware Issue Tracking
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements MCP Agent Mail's messaging and file reservations.
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements Liquid Mail's shared log for progress, decisions, and cross-session context.
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
### Conventions
- **Single source of truth:** Beads for task status/priority/dependencies; Agent Mail for conversation and audit
- **Shared identifiers:** Use Beads issue ID (e.g., `br-123`) as Mail `thread_id` and prefix subjects with `[br-123]`
- **Reservations:** When starting a task, call `file_reservation_paths()` with the issue ID in `reason`
- **Single source of truth:** Beads for task status/priority/dependencies; Liquid Mail for conversation/decisions
- **Shared identifiers:** Include the Beads issue ID in posts (e.g., `[br-123] Topic validation rules`)
- **Decisions before action:** Post `DECISION:` messages before risky changes, not after
### Typical Agent Flow
@@ -195,35 +148,34 @@ Beads provides a lightweight, dependency-aware issue database and CLI (`br` / be
br ready --json # Choose highest priority, no blockers
```
2. **Reserve edit surface (Mail):**
```
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true, reason="br-123")
2. **Check context (Liquid Mail):**
```bash
liquid-mail notify # See what changed since last session
liquid-mail query "br-123" # Find prior discussion on this issue
```
3. **Announce start (Mail):**
```
send_message(..., thread_id="br-123", subject="[br-123] Start: <title>", ack_required=true)
3. **Work and log progress:**
```bash
liquid-mail post --topic <workstream> "[br-123] START: <description>"
liquid-mail post "[br-123] FINDING: <what you discovered>"
liquid-mail post --decision "[br-123] DECISION: <what you decided and why>"
```
4. **Work and update:** Reply in-thread with progress
5. **Complete and release:**
4. **Complete (Beads is authority):**
```bash
br close br-123 --reason "Completed"
liquid-mail post "[br-123] Completed: <summary with commit ref>"
```
```
release_file_reservations(project_key, agent_name, paths=["src/**"])
```
Final Mail reply: `[br-123] Completed` with summary
### Mapping Cheat Sheet
| Concept | Value |
|---------|-------|
| Mail `thread_id` | `br-###` |
| Mail subject | `[br-###] ...` |
| File reservation `reason` | `br-###` |
| Commit messages | Include `br-###` for traceability |
| Concept | In Beads | In Liquid Mail |
|---------|----------|----------------|
| Work item | `br-###` (issue ID) | Include `[br-###]` in posts |
| Workstream | — | `--topic auth-system` |
| Subject prefix | — | `[br-###] ...` |
| Commit message | Include `br-###` | — |
| Status | `br update --status` | Post progress messages |
---
@@ -231,7 +183,7 @@ Beads provides a lightweight, dependency-aware issue database and CLI (`br` / be
bv is a graph-aware triage engine for Beads projects (`.beads/beads.jsonl`). It computes PageRank, betweenness, critical path, cycles, HITS, eigenvector, and k-core metrics deterministically.
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (messaging, work claiming, file reservations), use MCP Agent Mail.
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (progress logging, decisions, cross-session context), use Liquid Mail.
**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**
@@ -835,3 +787,157 @@ Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fa
- ❌ Full scan per edit → ✅ Scope to file
- ❌ Fix symptom (`if (x) { x.y }`) → ✅ Root cause (`x?.y`)
````
<!-- BEGIN LIQUID MAIL (v:48d7b3fc) -->
## Integrating Liquid Mail with Beads
**Beads** manages task status, priority, and dependencies (`br` CLI).
**Liquid Mail** provides the shared log—progress, decisions, and context that survives sessions.
### Conventions
- **Single source of truth**: Beads owns task state; Liquid Mail owns conversation/decisions
- **Shared identifiers**: Include the Beads issue ID in posts (e.g., `[lm-jht] Topic validation rules`)
- **Decisions before action**: Post `DECISION:` messages before risky changes, not after
- **Identity in user updates**: In every user-facing reply, include your window-name (derived from `LIQUID_MAIL_WINDOW_ID`) so humans can distinguish concurrent agents.
### Typical Flow
**1. Pick ready work (Beads)**
```bash
br ready # Find available work (no blockers)
br show lm-jht # Review details
br update lm-jht --status in_progress
```
**2. Check context (Liquid Mail)**
```bash
liquid-mail notify # See what changed since last session
liquid-mail query "lm-jht" # Find prior discussion on this issue
```
**3. Work and log progress (topic required)**
The `--topic` flag is required for your first post. After that, the topic is pinned to your window.
```bash
liquid-mail post --topic auth-system "[lm-jht] START: Reviewing current topic id patterns"
liquid-mail post "[lm-jht] FINDING: IDs like lm3189... are being used as topic names"
liquid-mail post "[lm-jht] NEXT: Add validation + rename guidance"
```
**4. Decisions before risky changes**
```bash
liquid-mail post --decision "[lm-jht] DECISION: Reject UUID-like topic names; require slugs"
# Then implement
```
### Decision Conflicts (Preflight)
When you post a decision (via `--decision` or a `DECISION:` line), Liquid Mail can preflight-check for conflicts with prior decisions **in the same topic**.
- If a conflict is detected, `liquid-mail post` fails with `DECISION_CONFLICT`.
- Review prior decisions: `liquid-mail decisions --topic <topic>`.
- If you intend to supersede the old decision, re-run with `--yes` and include what changed and why.
**5. Complete (Beads is authority)**
```bash
br close lm-jht # Mark complete in Beads
liquid-mail post "[lm-jht] Completed: Topic validation shipped in 177267d"
```
### Posting Format
- **Short** (5-15 lines, not walls of text)
- **Prefixed** with ALL-CAPS tags: `FINDING:`, `DECISION:`, `QUESTION:`, `NEXT:`
- **Include file paths** so others can jump in: `src/services/auth.ts:42`
- **Include issue IDs** in brackets: `[lm-jht]`
- **User-facing replies**: include `AGENT: <window-name>` near the top. Get it with `liquid-mail window name`.
### Topics (Required)
Liquid Mail organizes messages into **topics** (Honcho sessions). Topics are **soft boundaries**—search spans all topics by default.
**Rule:** `liquid-mail post` requires a topic:
- Provide `--topic <name>`, OR
- Post inside a window that already has a pinned topic.
Topic names must be:
- 450 characters
- lowercase letters/numbers with hyphens
- start with a letter, end with a letter/number
- no consecutive hyphens
- not reserved (`all`, `new`, `help`, `merge`, `rename`, `list`)
- not UUID-like (`lm<32-hex>` or standard UUIDs)
Good examples: `auth-system`, `db-system`, `dashboards`
Commands:
- **List topics (newest first)**: `liquid-mail topics`
- **Find context across topics**: `liquid-mail query "auth"`, then pick a topic name
- **Rename a topic (alias)**: `liquid-mail topic rename <old> <new>`
- **Merge two topics into a new one**: `liquid-mail topic merge <A> <B> --into <C>`
Examples (component topic + Beads id in the subject):
```bash
liquid-mail post --topic auth-system "[lm-jht] START: Investigating token refresh failures"
liquid-mail post --topic auth-system "[lm-jht] FINDING: refresh happens in middleware, not service layer"
liquid-mail post --topic auth-system --decision "[lm-jht] DECISION: Move refresh logic into AuthService"
liquid-mail post --topic dashboards "[lm-1p5] START: Adding latency panel"
```
### Context Refresh (Before New Work / After Redirects)
If you see redirect/merge messages, refresh context before acting:
```bash
liquid-mail notify
liquid-mail window status --json
liquid-mail summarize --topic <topic>
liquid-mail decisions --topic <topic>
```
If you discover a newer "canonical" topic (for example after a topic merge), switch to it explicitly:
```bash
liquid-mail post --topic <new-topic> "[lm-xxxx] CONTEXT: Switching topics (rename/merge)"
```
### Live Updates (Polling)
Liquid Mail is pull-based by default (you run `notify`). For near-real-time updates:
```bash
liquid-mail watch --topic <topic> # watch a topic
liquid-mail watch # or watch your pinned topic
```
### Mapping Cheat-Sheet
| Concept | In Beads | In Liquid Mail |
|---------|----------|----------------|
| Work item | `lm-jht` (issue ID) | Include `[lm-jht]` in posts |
| Workstream | — | `--topic auth-system` |
| Subject prefix | — | `[lm-jht] ...` |
| Commit message | Include `lm-jht` | — |
| Status | `br update --status` | Post progress messages |
### Pitfalls
- **Don't manage tasks in Liquid Mail**—Beads is the single task queue
- **Always include `lm-xxx`** in posts to avoid ID drift across tools
- **Don't dump logs**—keep posts short and structured
### Quick Reference
| Need | Command |
|------|---------|
| What changed? | `liquid-mail notify` |
| Log progress | `liquid-mail post "[lm-xxx] ..."` |
| Before risky change | `liquid-mail post --decision "[lm-xxx] DECISION: ..."` |
| Find history | `liquid-mail query "search term"` |
| Prior decisions | `liquid-mail decisions --topic <topic>` |
| Show config | `liquid-mail config` |
| List topics | `liquid-mail topics` |
| Rename topic | `liquid-mail topic rename <old> <new>` |
| Merge topics | `liquid-mail topic merge <A> <B> --into <C>` |
| Polling watch | `liquid-mail watch [--topic <topic>]` |
<!-- END LIQUID MAIL -->

5
Cargo.lock generated
View File

@@ -171,9 +171,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "charmed-lipgloss"
version = "0.1.2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
checksum = "a5986a4a6d84055da99e44a6c532fd412d636fe5c3fe17da105a7bf40287ccd1"
dependencies = [
"bitflags",
"colored",
@@ -183,6 +183,7 @@ dependencies = [
"thiserror",
"toml",
"tracing",
"unicode-segmentation",
"unicode-width 0.1.14",
]

View File

@@ -25,7 +25,7 @@ clap_complete = "4"
dialoguer = "0.12"
console = "0.16"
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"
# HTTP

290
docs/lore-me-spec.md Normal file
View File

@@ -0,0 +1,290 @@
# `lore me` — Personal Work Dashboard
## Overview
A personal dashboard command that shows everything relevant to the configured user: open issues, authored MRs, MRs under review, and recent activity. Attention state is computed from GitLab interaction data (comments) with no local state tracking.
## Command Interface
```
lore me # Full dashboard (default project or all)
lore me --issues # Issues section only
lore me --mrs # MRs section only (authored + reviewing)
lore me --activity # Activity feed only
lore me --issues --mrs # Multiple sections (combinable)
lore me --all # All synced projects (overrides default_project)
lore me --since 2d # Activity window (default: 30d)
lore me --project group/repo # Scope to one project
lore me --user jdoe # Override configured username
```
Standard global flags: `--robot`/`-J`, `--fields`, `--color`, `--icons`.
---
## Acceptance Criteria
### AC-1: Configuration
- **AC-1.1**: New optional field `gitlab.username` (string) in config.json
- **AC-1.2**: Resolution order: `--user` CLI flag > `config.gitlab.username` > exit code 2 with actionable error message suggesting how to set it
- **AC-1.3**: Username is case-sensitive (matches GitLab usernames exactly)
### AC-2: Command Interface
- **AC-2.1**: New command `lore me` — single command with flags (matches `who` pattern)
- **AC-2.2**: Section filter flags: `--issues`, `--mrs`, `--activity` — combinable. Passing multiple shows those sections. No flags = full dashboard (all sections).
- **AC-2.3**: `--since <duration>` controls activity feed window, default 30 days. Only affects the activity section; work item sections always show all open items regardless of `--since`.
- **AC-2.4**: `--project <path>` scopes to a single project
- **AC-2.5**: `--user <username>` overrides configured username
- **AC-2.6**: `--all` flag shows all synced projects (overrides default_project)
- **AC-2.7**: `--project` and `--all` are mutually exclusive — passing both is exit code 2
- **AC-2.8**: Standard global flags: `--robot`/`-J`, `--fields`, `--color`, `--icons`
### AC-3: "My Items" Definition
- **AC-3.1**: Issues assigned to me (`issue_assignees.username`). Authorship alone does NOT qualify an issue.
- **AC-3.2**: MRs authored by me (`merge_requests.author_username`)
- **AC-3.3**: MRs where I'm a reviewer (`mr_reviewers.username`)
- **AC-3.4**: Scope is **Assigned (issues) + Authored/Reviewing (MRs)** — no participation/mention expansion
- **AC-3.5**: MR assignees (`mr_assignees`) are NOT used — in Pattern 1 workflows (author = assignee), this is redundant with authorship
- **AC-3.6**: Activity feed uses CURRENT association only — if you've been unassigned from an issue, activity on it no longer appears. This keeps the query simple and the feed relevant.
### AC-4: Attention State Model
- **AC-4.1**: Computed per-item from synced GitLab data, no local state tracking
- **AC-4.2**: Interaction signal: notes authored by the user (`notes.author_username = me` where `is_system = 0`)
- **AC-4.3**: Future: award emoji will extend interaction signals (separate bead)
- **AC-4.4**: States (evaluated in this order — first match wins):
1. `not_ready`: MR only — `draft=1` AND zero entries in `mr_reviewers`
2. `needs_attention`: Others' latest non-system note > user's latest non-system note
3. `stale`: Entity has at least one non-system note from someone, but the most recent note from anyone is older than 30 days. Items with ZERO notes are NOT stale — they're `not_started`.
4. `not_started`: User has zero non-system notes on this entity (regardless of whether others have commented)
5. `awaiting_response`: User's latest non-system note timestamp >= all others' latest non-system note timestamps (including when user is the only commenter)
- **AC-4.5**: Applied to all item types (issues, authored MRs, reviewing MRs)
### AC-5: Dashboard Sections
**AC-5.1: Open Issues**
- Source: `issue_assignees.username = me`, state = opened
- Fields: project path, iid, title, status_name (work item status), attention state, relative time since updated
- Sort: attention-first (needs_attention > not_started > awaiting_response > stale), then most recently updated within same state
- No limit, no truncation — show all
**AC-5.2: Open MRs — Authored**
- Source: `merge_requests.author_username = me`, state = opened
- Fields: project path, iid, title, draft indicator, detailed_merge_status, attention state, relative time
- Sort: same as issues
**AC-5.3: Open MRs — Reviewing**
- Source: `mr_reviewers.username = me`, state = opened
- Fields: project path, iid, title, MR author username, draft indicator, attention state, relative time
- Sort: same as issues
**AC-5.4: Activity Feed**
- Sources (all within `--since` window, default 30d):
- Human comments (`notes.is_system = 0`) on my items
- State events (`resource_state_events`) on my items
- Label events (`resource_label_events`) on my items
- Milestone events (`resource_milestone_events`) on my items
- Assignment/reviewer system notes (see AC-12 for patterns) on my items
- "My items" for the activity feed = items I'm CURRENTLY associated with per AC-3 (current assignment state, not historical)
- Includes activity on items regardless of open/closed state
- Own actions included but flagged (`is_own: true` in robot, `(you)` suffix + dimmed in human)
- Sort: newest first (chronological descending)
- No limit, no truncation — show all events
**AC-5.5: Summary Header**
- Counts: projects, open issues, authored MRs, reviewing MRs, needs_attention count
- Attention legend (human mode): icon + label for each state
### AC-6: Human Output — Visual Design
**AC-6.1: Layout**
- Section card style with `section_divider` headers
- Legend at top explains attention icons
- Two-line per item: main data on line 1, project path on line 2 (indented)
- When scoped to single project (`--project`), suppress project path line (redundant)
**AC-6.2: Attention Icons (three tiers)**
| State | Nerd Font | Unicode | ASCII | Color |
|-------|-----------|---------|-------|-------|
| needs_attention | `\uf0f3` bell | `◆` | `[!]` | amber (warning) |
| not_started | `\uf005` star | `★` | `[*]` | cyan (info) |
| awaiting_response | `\uf017` clock | `◷` | `[~]` | dim (muted) |
| stale | `\uf54c` skull | `☠` | `[x]` | dim (muted) |
**AC-6.3: Color Vocabulary** (matches existing lore palette)
- Issue refs (#N): cyan
- MR refs (!N): purple
- Usernames (@name): cyan
- Opened state: green
- Merged state: purple
- Closed state: dim
- Draft indicator: gray
- Own actions: dimmed + `(you)` suffix
- Timestamps: dim (relative time)
**AC-6.4: Activity Event Badges**
| Event | Nerd/Unicode (colored bg) | ASCII fallback |
|-------|--------------------------|----------------|
| note | cyan bg, dark text | `[note]` cyan text |
| status | amber bg, dark text | `[status]` amber text |
| label | purple bg, white text | `[label]` purple text |
| assign | green bg, dark text | `[assign]` green text |
| milestone | magenta bg, white text | `[milestone]` magenta text |
Fallback: when background colors aren't available (ASCII mode), use colored text with brackets instead of background pills.
**AC-6.5: Labels**
- Human mode: not shown
- Robot mode: included in JSON
### AC-7: Robot Output
- **AC-7.1**: Standard `{ok, data, meta}` envelope
- **AC-7.2**: `data` contains: `username`, `since_iso`, `summary` (counts + `needs_attention_count`), `open_issues[]`, `open_mrs_authored[]`, `reviewing_mrs[]`, `activity[]`
- **AC-7.3**: Each item includes: project, iid, title, state, attention_state (programmatic: `needs_attention`, `not_started`, `awaiting_response`, `stale`, `not_ready`), labels, updated_at_iso, web_url
- **AC-7.4**: Issues include `status_name` (work item status)
- **AC-7.5**: MRs include `draft`, `detailed_merge_status`, `author_username` (reviewing section)
- **AC-7.6**: Activity items include: `timestamp_iso`, `event_type`, `entity_type`, `entity_iid`, `project`, `actor`, `is_own`, `summary`, `body_preview` (for notes, truncated to 200 chars)
- **AC-7.7**: `--fields minimal` preset: `iid`, `title`, `attention_state`, `updated_at_iso` (work items); `timestamp_iso`, `event_type`, `entity_iid`, `actor` (activity)
- **AC-7.8**: Metadata-only depth — agents drill into specific items with `timeline`, `issues`, `mrs` for full context
- **AC-7.9**: No limits, no truncation on any array
### AC-8: Cross-Project Behavior
- **AC-8.1**: If `config.default_project` is set, scope to that project by default. If no default project, show all synced projects.
- **AC-8.2**: `--all` flag overrides default project and shows all synced projects
- **AC-8.3**: `--project` flag narrows to a specific project (supports fuzzy match like other commands)
- **AC-8.4**: `--project` and `--all` are mutually exclusive (exit 2 if both passed)
- **AC-8.5**: Project path shown per-item in both human and robot output (suppressed in human when single-project scoped per AC-6.1)
### AC-9: Sort Order
- **AC-9.1**: Work item sections: attention-first, then most recently updated
- **AC-9.2**: Attention priority: `needs_attention` > `not_started` > `awaiting_response` > `stale` > `not_ready`
- **AC-9.3**: Activity feed: chronological descending (newest first)
### AC-10: Error Handling
- **AC-10.1**: No username configured and no `--user` flag → exit 2 with suggestion
- **AC-10.2**: No synced data → exit 17 with suggestion to run `lore sync`
- **AC-10.3**: Username found but no matching items → empty sections with summary showing zeros
- **AC-10.4**: `--project` and `--all` both passed → exit 2 with message
### AC-11: Relationship to Existing Commands
- **AC-11.1**: `who @username` remains for looking at anyone's workload
- **AC-11.2**: `lore me` is the self-view with attention intelligence
- **AC-11.3**: No deprecation of `who` — they serve different purposes
### AC-12: New Assignments Detection
- **AC-12.1**: Detect from system notes (`notes.is_system = 1`) matching these body patterns:
- `"assigned to @username"` — issue/MR assignment
- `"unassigned @username"` — removal (shown as `unassign` event type)
- `"requested review from @username"` — reviewer assignment (shown as `review_request` event type)
- **AC-12.2**: These appear in the activity feed with appropriate event types
- **AC-12.3**: Shows who performed the action (note author from the associated non-system context, or "system" if unavailable) and when (note created_at)
- **AC-12.4**: Pattern matching is case-insensitive and matches username at word boundary
---
## Out of Scope (Follow-Up Work)
- **Award emoji sync**: Extends attention signal with reaction timestamps. Requires new table + GitLab REST API integration. Note-level emoji sync has N+1 concern requiring smart batching.
- **Participation/mention expansion**: Broadening "my items" beyond assigned+authored.
- **Label filtering**: `--label` flag to scope dashboard by label.
---
## Design Notes
### Why No High-Water Mark
GitLab itself is the source of truth for "what I've engaged with." The attention state is computed by comparing the user's latest comment timestamp against others' latest comment timestamps on each item. No local cursor or mark is needed.
### Why Comments-Only (For Now)
Award emoji (reactions) are a valid "I've engaged" signal but aren't currently synced. The attention model is designed to incorporate emoji timestamps when available — adding them later requires no model changes.
### Why MR Assignees Are Excluded
GitLab MR workflows have three role fields: Author, Assignee, and Reviewer. In Pattern 1 workflows (the most common post-2020), the author assigns themselves — making assignee redundant with authorship. The Reviewing section uses `mr_reviewers` as the review signal.
### Attention State Evaluation Order
States are evaluated in priority order (first match wins):
```
1. not_ready — MR-only: draft=1 AND no reviewers
2. needs_attention — others commented after me
3. stale — had activity, but nothing in 30d (NOT for zero-comment items)
4. not_started — I have zero comments (may or may not have others' comments)
5. awaiting_response — I commented last (or I'm the only commenter)
```
Edge cases:
- Zero comments from anyone → `not_started` (NOT stale)
- Only my comments, none from others → `awaiting_response`
- Only others' comments, none from me → `not_started` (I haven't engaged)
- Wait: this conflicts with `needs_attention` (step 2). If others have commented and I haven't, then others' latest > my latest (NULL). This should be `needs_attention`, not `not_started`.
Corrected logic:
- `needs_attention` takes priority over `not_started` when others HAVE commented but I haven't. The distinction: `not_started` only applies when NOBODY has commented.
```
1. not_ready — MR-only: draft=1 AND no reviewers
2. needs_attention — others have non-system notes AND (I have none OR others' latest > my latest)
3. stale — latest note from anyone is older than 30 days
4. awaiting_response — my latest >= others' latest (I'm caught up)
5. not_started — zero non-system notes from anyone
```
### Attention State Computation (SQL Sketch)
```sql
WITH my_latest AS (
SELECT d.issue_id, d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?me AND n.is_system = 0
GROUP BY d.issue_id, d.merge_request_id
),
others_latest AS (
SELECT d.issue_id, d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?me AND n.is_system = 0
GROUP BY d.issue_id, d.merge_request_id
),
any_latest AS (
SELECT d.issue_id, d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
GROUP BY d.issue_id, d.merge_request_id
)
SELECT
CASE
-- MR-only: draft with no reviewers
WHEN entity_type = 'mr' AND draft = 1
AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = entity_id)
THEN 'not_ready'
-- Others commented and I haven't caught up (or never engaged)
WHEN others.ts IS NOT NULL AND (my.ts IS NULL OR others.ts > my.ts)
THEN 'needs_attention'
-- Had activity but gone quiet for 30d
WHEN any.ts IS NOT NULL AND any.ts < ?now_minus_30d
THEN 'stale'
-- I've responded and I'm caught up
WHEN my.ts IS NOT NULL AND my.ts >= COALESCE(others.ts, 0)
THEN 'awaiting_response'
-- Nobody has commented at all
ELSE 'not_started'
END AS attention_state
FROM ...
```

View File

@@ -286,6 +286,19 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
),
("show", &["--project"]),
("reset", &["--yes"]),
(
"me",
&[
"--issues",
"--mrs",
"--activity",
"--since",
"--project",
"--all",
"--user",
"--fields",
],
),
];
/// Valid values for enum-like flags, used for post-clap error enhancement.

View File

@@ -96,6 +96,7 @@ fn test_config(default_project: Option<&str>) -> Config {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: None,
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),

View File

@@ -0,0 +1,749 @@
use super::*;
use crate::cli::commands::me::types::{ActivityEventType, AttentionState};
use crate::core::db::{create_connection, run_migrations};
use crate::core::time::now_ms;
use rusqlite::Connection;
use std::path::Path;
// ─── Helpers ────────────────────────────────────────────────────────────────
fn setup_test_db() -> Connection {
let conn = create_connection(Path::new(":memory:")).unwrap();
run_migrations(&conn).unwrap();
conn
}
fn insert_project(conn: &Connection, id: i64, path: &str) {
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
id,
id * 100,
path,
format!("https://git.example.com/{path}")
],
)
.unwrap();
}
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");
}
fn insert_issue_with_state(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
) {
let ts = now_ms();
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("Issue {iid}"),
state,
author,
ts,
ts,
ts
],
)
.unwrap();
}
fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) {
conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)",
rusqlite::params![issue_id, username],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_mr(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
draft: bool,
) {
let ts = now_ms();
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, draft, last_seen_at, updated_at, created_at, merged_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("MR {iid}"),
author,
state,
i32::from(draft),
ts,
ts,
ts,
if state == "merged" { Some(ts) } else { None::<i64> }
],
)
.unwrap();
}
fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
conn.execute(
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
rusqlite::params![mr_id, username],
)
.unwrap();
}
fn insert_discussion(
conn: &Connection,
id: i64,
project_id: i64,
mr_id: Option<i64>,
issue_id: Option<i64>,
) {
let noteable_type = if mr_id.is_some() {
"MergeRequest"
} else {
"Issue"
};
let ts = now_ms();
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, issue_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 0, ?7, ?8)",
rusqlite::params![
id,
format!("disc-{id}"),
project_id,
mr_id,
issue_id,
noteable_type,
ts,
ts
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_note_at(
conn: &Connection,
id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
is_system: bool,
body: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, 'DiscussionNote', ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
id * 10,
discussion_id,
project_id,
i32::from(is_system),
author,
body,
created_at,
created_at,
now_ms()
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_state_event(
conn: &Connection,
id: i64,
project_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
state: &str,
actor: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO resource_state_events (id, gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![id, id * 10, project_id, issue_id, mr_id, state, actor, created_at],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_label_event(
conn: &Connection,
id: i64,
project_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
action: &str,
label_name: &str,
actor: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO resource_label_events (id, gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
id,
id * 10,
project_id,
issue_id,
mr_id,
action,
label_name,
actor,
created_at
],
)
.unwrap();
}
// ─── Open Issues Tests (Task #7) ───────────────────────────────────────────
#[test]
fn open_issues_returns_assigned_only() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 1, 43, "someone");
// Only assign issue 42 to alice
insert_assignee(&conn, 10, "alice");
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 42);
}
#[test]
fn open_issues_excludes_closed() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue_with_state(&conn, 11, 1, 43, "someone", "closed");
insert_assignee(&conn, 10, "alice");
insert_assignee(&conn, 11, "alice");
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 42);
}
#[test]
fn open_issues_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 2, 43, "someone");
insert_assignee(&conn, 10, "alice");
insert_assignee(&conn, 11, "alice");
// Filter to project 1 only
let results = query_open_issues(&conn, "alice", &[1]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
#[test]
fn open_issues_empty_when_unassigned() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "alice");
// alice authored but is NOT assigned
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert!(results.is_empty());
}
// ─── Attention State Tests (Task #10) ──────────────────────────────────────
#[test]
fn attention_state_not_started_no_notes() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::NotStarted);
}
#[test]
fn attention_state_needs_attention_others_replied() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
// alice comments first, then bob replies after
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t1);
insert_note_at(&conn, 201, disc_id, 1, "bob", false, "reply", t2);
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::NeedsAttention);
}
#[test]
fn attention_state_awaiting_response() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
// bob first, then alice replies (alice's latest >= others' latest)
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "question", t1);
insert_note_at(&conn, 201, disc_id, 1, "alice", false, "my reply", t2);
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse);
}
// ─── Authored MRs Tests (Task #8) ─────────────────────────────────────────
#[test]
fn authored_mrs_returns_own_only() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
insert_mr(&conn, 11, 1, 100, "bob", "opened", false);
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 99);
}
#[test]
fn authored_mrs_excludes_merged() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
insert_mr(&conn, 11, 1, 100, "alice", "merged", false);
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 99);
}
#[test]
fn authored_mrs_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
insert_mr(&conn, 11, 2, 100, "alice", "opened", false);
let results = query_authored_mrs(&conn, "alice", &[2]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-b");
}
#[test]
fn authored_mr_not_ready_when_draft_no_reviewers() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", true);
// No reviewers added
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].draft);
assert_eq!(results[0].attention_state, AttentionState::NotReady);
}
#[test]
fn authored_mr_not_ready_overridden_when_has_reviewers() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", true);
insert_reviewer(&conn, 10, "bob");
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
// Draft with reviewers -> not_started (not not_ready), since no one has commented
assert_eq!(results[0].attention_state, AttentionState::NotStarted);
}
// ─── Reviewing MRs Tests (Task #9) ────────────────────────────────────────
#[test]
fn reviewing_mrs_returns_reviewer_items() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_mr(&conn, 11, 1, 100, "charlie", "opened", false);
insert_reviewer(&conn, 10, "alice");
// alice is NOT a reviewer of MR 100
let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 99);
}
#[test]
fn reviewing_mrs_includes_author_username() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice");
let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].author_username, Some("bob".to_string()));
}
#[test]
fn reviewing_mrs_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_mr(&conn, 11, 2, 100, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice");
insert_reviewer(&conn, 11, "alice");
let results = query_reviewing_mrs(&conn, "alice", &[1]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
// ─── Activity Feed Tests (Tasks #11-13) ────────────────────────────────────
#[test]
fn activity_note_on_assigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
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, "a comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_iid, 42);
assert_eq!(results[0].entity_type, "issue");
assert!(!results[0].is_own);
}
#[test]
fn activity_note_on_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "nice work", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_type, "mr");
assert_eq!(results[0].entity_iid, 99);
}
#[test]
fn activity_state_event_on_my_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let t = now_ms() - 1000;
insert_state_event(&conn, 300, 1, Some(10), None, "closed", "bob", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::StatusChange);
assert_eq!(results[0].summary, "closed");
}
#[test]
fn activity_label_event_on_my_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let t = now_ms() - 1000;
insert_label_event(&conn, 400, 1, Some(10), None, "add", "bug", "bob", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::LabelChange);
assert!(results[0].summary.contains("bug"));
}
#[test]
fn activity_excludes_unassociated_items() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
// Issue NOT assigned to alice
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, "a comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert!(
results.is_empty(),
"should not see activity on unassigned issues"
);
}
#[test]
fn activity_since_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let old_t = now_ms() - 100_000_000; // ~1 day ago
let recent_t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "old comment", old_t);
insert_note_at(
&conn,
201,
disc_id,
1,
"bob",
false,
"new comment",
recent_t,
);
// since = 50 seconds ago, should only get the recent note
let since = now_ms() - 50_000;
let results = query_activity(&conn, "alice", &[], since).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].body_preview, Some("new comment".to_string()));
}
#[test]
fn activity_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 2, 43, "someone");
insert_assignee(&conn, 10, "alice");
insert_assignee(&conn, 11, "alice");
let disc_a = 100;
let disc_b = 101;
insert_discussion(&conn, disc_a, 1, None, Some(10));
insert_discussion(&conn, disc_b, 2, None, Some(11));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_a, 1, "bob", false, "comment a", t);
insert_note_at(&conn, 201, disc_b, 2, "bob", false, "comment b", t);
// Filter to project 1 only
let results = query_activity(&conn, "alice", &[1], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
#[test]
fn activity_sorted_newest_first() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "first", t1);
insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "second", t2);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 2);
assert!(
results[0].timestamp >= results[1].timestamp,
"should be sorted newest first"
);
}
#[test]
fn activity_is_own_flag() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
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, "alice", false, "my comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].is_own);
}
// ─── Assignment Detection Tests (Task #12) ─────────────────────────────────
#[test]
fn activity_assignment_system_note() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
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", true, "assigned to @alice", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Assign);
}
#[test]
fn activity_unassignment_system_note() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
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", true, "unassigned @alice", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Unassign);
}
#[test]
fn activity_review_request_system_note() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
true,
"requested review from @alice",
t,
);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
}
// ─── Helper Tests ──────────────────────────────────────────────────────────
#[test]
fn parse_attention_state_all_variants() {
assert_eq!(
parse_attention_state("needs_attention"),
AttentionState::NeedsAttention
);
assert_eq!(
parse_attention_state("not_started"),
AttentionState::NotStarted
);
assert_eq!(
parse_attention_state("awaiting_response"),
AttentionState::AwaitingResponse
);
assert_eq!(parse_attention_state("stale"), AttentionState::Stale);
assert_eq!(parse_attention_state("not_ready"), AttentionState::NotReady);
assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted);
}
#[test]
fn parse_event_type_all_variants() {
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
assert_eq!(
parse_event_type("status_change"),
ActivityEventType::StatusChange
);
assert_eq!(
parse_event_type("label_change"),
ActivityEventType::LabelChange
);
assert_eq!(parse_event_type("assign"), ActivityEventType::Assign);
assert_eq!(parse_event_type("unassign"), ActivityEventType::Unassign);
assert_eq!(
parse_event_type("review_request"),
ActivityEventType::ReviewRequest
);
assert_eq!(
parse_event_type("milestone_change"),
ActivityEventType::MilestoneChange
);
assert_eq!(parse_event_type("unknown"), ActivityEventType::Note);
}
#[test]
fn build_project_clause_empty() {
assert_eq!(build_project_clause("i.project_id", &[]), "");
}
#[test]
fn build_project_clause_single() {
let clause = build_project_clause("i.project_id", &[1]);
assert_eq!(clause, "AND i.project_id = ?2");
}
#[test]
fn build_project_clause_multiple() {
let clause = build_project_clause("i.project_id", &[1, 2, 3]);
assert_eq!(clause, "AND i.project_id IN (?2,?3,?4)");
}
#[test]
fn build_project_clause_at_custom_start() {
let clause = build_project_clause_at("p.id", &[1, 2], 3);
assert_eq!(clause, "AND p.id IN (?3,?4)");
}

424
src/cli/commands/me/mod.rs Normal file
View File

@@ -0,0 +1,424 @@
pub mod queries;
pub mod render_human;
pub mod render_robot;
pub mod types;
use std::collections::HashSet;
use rusqlite::Connection;
use crate::Config;
use crate::cli::MeArgs;
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};
/// Default activity lookback: 30 days in milliseconds (AC-2.3).
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 30;
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
/// Resolve the effective username from CLI flag or config.
///
/// Precedence: `--user` flag > `config.gitlab.username` > error (AC-1.2).
pub fn resolve_username<'a>(args: &'a MeArgs, config: &'a Config) -> Result<&'a str> {
if let Some(ref user) = args.user {
return Ok(user.as_str());
}
if let Some(ref username) = config.gitlab.username {
return Ok(username.as_str());
}
Err(LoreError::ConfigInvalid {
details: "No GitLab username configured. Set gitlab.username in config.json or pass --user <username>.".to_string(),
})
}
/// Resolve the project scope for the dashboard.
///
/// Returns a list of project IDs to filter by. An empty vec means "all projects".
///
/// Precedence (AC-8):
/// - `--project` and `--all` both set → error (AC-8.4, clap also enforces this)
/// - `--all` → empty vec (all projects)
/// - `--project` → resolve to single project ID via fuzzy match
/// - config.default_project → resolve that
/// - no default → empty vec (all projects)
pub fn resolve_project_scope(
conn: &Connection,
args: &MeArgs,
config: &Config,
) -> Result<Vec<i64>> {
if args.all {
return Ok(Vec::new());
}
if let Some(ref project) = args.project {
let id = resolve_project(conn, project)?;
return Ok(vec![id]);
}
if let Some(ref dp) = config.default_project {
let id = resolve_project(conn, dp)?;
return Ok(vec![id]);
}
Ok(Vec::new())
}
/// Run the `lore me` personal dashboard command.
///
/// Orchestrates: username resolution → project scope → query execution →
/// summary computation → dashboard assembly → rendering.
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let start = std::time::Instant::now();
// 1. Open DB
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// 2. Check for synced data (AC-10.2)
let has_data: bool = conn
.query_row("SELECT EXISTS(SELECT 1 FROM projects LIMIT 1)", [], |row| {
row.get(0)
})
.unwrap_or(false);
if !has_data {
return Err(LoreError::NotFound(
"No synced data found. Run `lore sync` first to fetch your GitLab data.".to_string(),
));
}
// 3. Resolve username
let username = resolve_username(args, config)?;
// 4. Resolve project scope
let project_ids = resolve_project_scope(&conn, args, config)?;
let single_project = project_ids.len() == 1;
// 5. Parse --since (default 30d for activity feed, AC-2.3)
let since_ms = match args.since.as_deref() {
Some(raw) => parse_since(raw).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --since value '{raw}'. Expected: 7d, 2w, 3m, YYYY-MM-DD, or Unix-ms timestamp."
))
})?,
None => crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY,
};
// 6. 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
let open_issues = if want_issues {
query_open_issues(&conn, username, &project_ids)?
} else {
Vec::new()
};
let open_mrs_authored = if want_mrs {
query_authored_mrs(&conn, username, &project_ids)?
} else {
Vec::new()
};
let reviewing_mrs = if want_mrs {
query_reviewing_mrs(&conn, username, &project_ids)?
} else {
Vec::new()
};
let activity = if want_activity {
query_activity(&conn, username, &project_ids, since_ms)?
} else {
Vec::new()
};
// 8. Compute summary
let needs_attention_count = open_issues
.iter()
.filter(|i| i.attention_state == AttentionState::NeedsAttention)
.count()
+ open_mrs_authored
.iter()
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count()
+ reviewing_mrs
.iter()
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count();
// Count distinct projects across all items
let mut project_paths: HashSet<&str> = HashSet::new();
for i in &open_issues {
project_paths.insert(&i.project_path);
}
for m in &open_mrs_authored {
project_paths.insert(&m.project_path);
}
for m in &reviewing_mrs {
project_paths.insert(&m.project_path);
}
let summary = MeSummary {
project_count: project_paths.len(),
open_issue_count: open_issues.len(),
authored_mr_count: open_mrs_authored.len(),
reviewing_mr_count: reviewing_mrs.len(),
needs_attention_count,
};
// 9. Assemble dashboard
let dashboard = MeDashboard {
username: username.to_string(),
since_ms: Some(since_ms),
summary,
open_issues,
open_mrs_authored,
reviewing_mrs,
activity,
};
// 10. Render
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
let fields = args.fields.as_deref();
render_robot::print_me_json(&dashboard, elapsed_ms, fields)?;
} else if show_all {
render_human::print_me_dashboard(&dashboard, single_project);
} else {
render_human::print_me_dashboard_filtered(
&dashboard,
single_project,
want_issues,
want_mrs,
want_activity,
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::{
EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, StorageConfig,
SyncConfig,
};
use crate::core::db::{create_connection, run_migrations};
use std::path::Path;
fn test_config(username: Option<&str>) -> Config {
Config {
gitlab: GitLabConfig {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: username.map(String::from),
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),
}],
default_project: None,
sync: SyncConfig::default(),
storage: StorageConfig::default(),
embedding: EmbeddingConfig::default(),
logging: LoggingConfig::default(),
scoring: ScoringConfig::default(),
}
}
fn test_args(user: Option<&str>) -> MeArgs {
MeArgs {
issues: false,
mrs: false,
activity: false,
since: None,
project: None,
all: false,
user: user.map(String::from),
fields: None,
}
}
#[test]
fn resolve_username_cli_flag_wins() {
let config = test_config(Some("config-user"));
let args = test_args(Some("cli-user"));
let result = resolve_username(&args, &config).unwrap();
assert_eq!(result, "cli-user");
}
#[test]
fn resolve_username_falls_back_to_config() {
let config = test_config(Some("config-user"));
let args = test_args(None);
let result = resolve_username(&args, &config).unwrap();
assert_eq!(result, "config-user");
}
#[test]
fn resolve_username_errors_when_both_absent() {
let config = test_config(None);
let args = test_args(None);
let err = resolve_username(&args, &config).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("username"), "unexpected error: {msg}");
assert!(msg.contains("--user"), "should suggest --user flag: {msg}");
}
fn test_config_with_default_project(
username: Option<&str>,
default_project: Option<&str>,
) -> Config {
Config {
gitlab: GitLabConfig {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: username.map(String::from),
},
projects: vec![
ProjectConfig {
path: "group/project".to_string(),
},
ProjectConfig {
path: "other/repo".to_string(),
},
],
default_project: default_project.map(String::from),
sync: SyncConfig::default(),
storage: StorageConfig::default(),
embedding: EmbeddingConfig::default(),
logging: LoggingConfig::default(),
scoring: ScoringConfig::default(),
}
}
fn setup_test_db() -> Connection {
let conn = create_connection(Path::new(":memory:")).unwrap();
run_migrations(&conn).unwrap();
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
VALUES (2, 'other/repo', 'https://gitlab.example.com/other/repo')",
[],
)
.unwrap();
conn
}
#[test]
fn resolve_project_scope_all_flag_returns_empty() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.all = true;
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert!(ids.is_empty(), "expected empty for --all, got {ids:?}");
}
#[test]
fn resolve_project_scope_project_flag_resolves() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.project = Some("group/project".to_string());
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn resolve_project_scope_default_project() {
let conn = setup_test_db();
let config = test_config_with_default_project(Some("jdoe"), Some("other/repo"));
let args = test_args(None);
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn resolve_project_scope_no_default_returns_empty() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let args = test_args(None);
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert!(ids.is_empty(), "expected empty, got {ids:?}");
}
#[test]
fn resolve_project_scope_project_flag_fuzzy_match() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.project = Some("project".to_string());
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn resolve_project_scope_all_overrides_default_project() {
let conn = setup_test_db();
let config = test_config_with_default_project(Some("jdoe"), Some("group/project"));
let mut args = test_args(None);
args.all = true;
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert!(
ids.is_empty(),
"expected --all to override default_project, got {ids:?}"
);
}
#[test]
fn resolve_project_scope_project_flag_overrides_default() {
let conn = setup_test_db();
let config = test_config_with_default_project(Some("jdoe"), Some("group/project"));
let mut args = test_args(None);
args.project = Some("other/repo".to_string());
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1, "expected --project to override default");
// Verify it resolved the explicit project, not the default
let resolved_path: String = conn
.query_row(
"SELECT path_with_namespace FROM projects WHERE id = ?1",
rusqlite::params![ids[0]],
|row| row.get(0),
)
.unwrap();
assert_eq!(resolved_path, "other/repo");
}
#[test]
fn resolve_project_scope_unknown_project_errors() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.project = Some("nonexistent/project".to_string());
let err = resolve_project_scope(&conn, &args, &config).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"), "expected not found error: {msg}");
}
#[test]
fn show_all_sections_true_when_no_flags() {
let args = test_args(None);
assert!(args.show_all_sections());
}
#[test]
fn show_all_sections_false_with_issues_flag() {
let mut args = test_args(None);
args.issues = true;
assert!(!args.show_all_sections());
}
}

View File

@@ -0,0 +1,560 @@
// ─── Query Functions ────────────────────────────────────────────────────────
//
// SQL queries powering the `lore me` dashboard.
// Each function takes &Connection, username, optional project scope,
// and returns Result<Vec<StructType>>.
use rusqlite::Connection;
use crate::core::error::Result;
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr};
/// Stale threshold: items with no activity for 30 days are marked "stale".
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
// ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────
/// Query open issues assigned to the user via issue_assignees.
/// Returns issues sorted by attention state priority, then by most recently updated.
/// Attention state is computed inline using CTE-based note timestamp comparison.
pub fn query_open_issues(
conn: &Connection,
username: &str,
project_ids: &[i64],
) -> Result<Vec<MeIssue>> {
let project_clause = build_project_clause("i.project_id", project_ids);
let sql = format!(
"WITH note_ts AS (
SELECT d.issue_id,
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
MAX(n.created_at) AS any_ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
)
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
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})
THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.id
LEFT JOIN note_ts nt ON nt.issue_id = i.id
WHERE ia.username = ?1
AND i.state = 'opened'
{project_clause}
ORDER BY
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.any_ts IS NULL AND nt.my_ts IS NULL
THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 2
ELSE 1
END,
i.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
);
let params = build_params(username, project_ids);
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(6)?;
Ok(MeIssue {
iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
project_path: row.get(2)?,
status_name: row.get(3)?,
updated_at: row.get(4)?,
web_url: row.get(5)?,
attention_state: parse_attention_state(&attention_str),
labels: Vec::new(),
})
})?;
let mut issues: Vec<MeIssue> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
populate_issue_labels(conn, &mut issues)?;
Ok(issues)
}
// ─── Authored MRs (AC-5.2, Task #8) ────────────────────────────────────────
/// Query open MRs authored by the user.
pub fn query_authored_mrs(
conn: &Connection,
username: &str,
project_ids: &[i64],
) -> Result<Vec<MeMr>> {
let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!(
"WITH note_ts AS (
SELECT d.merge_request_id,
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
MAX(n.created_at) AS any_ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.updated_at, m.web_url,
CASE
WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) 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})
THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
WHERE m.author_username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
);
let params = build_params(username, project_ids);
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(7)?;
Ok(MeMr {
iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
project_path: row.get(2)?,
draft: row.get::<_, i32>(3)? != 0,
detailed_merge_status: row.get(4)?,
updated_at: row.get(5)?,
web_url: row.get(6)?,
attention_state: parse_attention_state(&attention_str),
author_username: None,
labels: Vec::new(),
})
})?;
let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
populate_mr_labels(conn, &mut mrs)?;
Ok(mrs)
}
// ─── Reviewing MRs (AC-5.3, Task #9) ───────────────────────────────────────
/// Query open MRs where user is a reviewer.
pub fn query_reviewing_mrs(
conn: &Connection,
username: &str,
project_ids: &[i64],
) -> Result<Vec<MeMr>> {
let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!(
"WITH note_ts AS (
SELECT d.merge_request_id,
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
MAX(n.created_at) AS any_ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.author_username, m.updated_at, m.web_url,
CASE
-- 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})
THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN mr_reviewers r ON r.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
WHERE r.username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
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.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
);
let params = build_params(username, project_ids);
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(8)?;
Ok(MeMr {
iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
project_path: row.get(2)?,
draft: row.get::<_, i32>(3)? != 0,
detailed_merge_status: row.get(4)?,
author_username: row.get(5)?,
updated_at: row.get(6)?,
web_url: row.get(7)?,
attention_state: parse_attention_state(&attention_str),
labels: Vec::new(),
})
})?;
let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
populate_mr_labels(conn, &mut mrs)?;
Ok(mrs)
}
// ─── Activity Feed (AC-5.4, Tasks #11-13) ──────────────────────────────────
/// Query activity events on items currently associated with the user.
/// Combines notes, state events, label events, milestone events, and
/// assignment/reviewer system notes into a unified feed sorted newest-first.
pub fn query_activity(
conn: &Connection,
username: &str,
project_ids: &[i64],
since_ms: i64,
) -> Result<Vec<MeActivityEvent>> {
// Build project filter for activity sources.
// Activity params: ?1=username, ?2=since_ms, ?3+=project_ids
let project_clause = build_project_clause_at("p.id", project_ids, 3);
// 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).
let my_issue_check = "EXISTS (
SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
)";
let my_mr_check = "(
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
OR EXISTS (SELECT 1 FROM mr_reviewers rv WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
)";
// Source 1: Human comments on my items
let notes_sql = format!(
"SELECT n.created_at, 'note',
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
n.author_username,
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
SUBSTR(n.body, 1, 200),
SUBSTR(n.body, 1, 200)
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
{project_clause}
AND (
(d.issue_id IS NOT NULL AND {issue_check})
OR (d.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
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: State events
let state_sql = format!(
"SELECT e.created_at, 'status_change',
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
e.actor_username,
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
e.state,
NULL
FROM resource_state_events e
JOIN projects p ON e.project_id = p.id
LEFT JOIN issues i ON e.issue_id = i.id
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
WHERE e.created_at >= ?2
{project_clause}
AND (
(e.issue_id IS NOT NULL AND {issue_check})
OR (e.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
);
// Source 3: Label events
let label_sql = format!(
"SELECT e.created_at, 'label_change',
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
e.actor_username,
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
(e.action || ' ' || COALESCE(e.label_name, '(deleted)')),
NULL
FROM resource_label_events e
JOIN projects p ON e.project_id = p.id
LEFT JOIN issues i ON e.issue_id = i.id
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
WHERE e.created_at >= ?2
{project_clause}
AND (
(e.issue_id IS NOT NULL AND {issue_check})
OR (e.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
);
// Source 4: Milestone events
let milestone_sql = format!(
"SELECT e.created_at, 'milestone_change',
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
e.actor_username,
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
(e.action || ' ' || COALESCE(e.milestone_title, '(deleted)')),
NULL
FROM resource_milestone_events e
JOIN projects p ON e.project_id = p.id
LEFT JOIN issues i ON e.issue_id = i.id
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
WHERE e.created_at >= ?2
{project_clause}
AND (
(e.issue_id IS NOT NULL AND {issue_check})
OR (e.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
);
// Source 5: Assignment/reviewer system notes (AC-12)
let assign_sql = format!(
"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),
p.path_with_namespace,
n.author_username,
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
n.body,
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
{project_clause}
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) || '%'
)
AND (
(d.issue_id IS NOT NULL AND {issue_check})
OR (d.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
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"),
);
let full_sql = format!(
"{notes_sql}
UNION ALL {state_sql}
UNION ALL {label_sql}
UNION ALL {milestone_sql}
UNION ALL {assign_sql}
ORDER BY 1 DESC"
);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(username.to_string()));
params.push(Box::new(since_ms));
for &pid in project_ids {
params.push(Box::new(pid));
}
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| {
let event_type_str: String = row.get(1)?;
Ok(MeActivityEvent {
timestamp: row.get(0)?,
event_type: parse_event_type(&event_type_str),
entity_type: row.get(2)?,
entity_iid: row.get(3)?,
project_path: row.get(4)?,
actor: row.get(5)?,
is_own: row.get::<_, i32>(6)? != 0,
summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
body_preview: row.get(8)?,
})
})?;
let events: Vec<MeActivityEvent> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(events)
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/// Parse attention state string from SQL CASE result.
fn parse_attention_state(s: &str) -> AttentionState {
match s {
"needs_attention" => AttentionState::NeedsAttention,
"not_started" => AttentionState::NotStarted,
"awaiting_response" => AttentionState::AwaitingResponse,
"stale" => AttentionState::Stale,
"not_ready" => AttentionState::NotReady,
_ => AttentionState::NotStarted,
}
}
/// Parse activity event type string from SQL.
fn parse_event_type(s: &str) -> ActivityEventType {
match s {
"note" => ActivityEventType::Note,
"status_change" => ActivityEventType::StatusChange,
"label_change" => ActivityEventType::LabelChange,
"assign" => ActivityEventType::Assign,
"unassign" => ActivityEventType::Unassign,
"review_request" => ActivityEventType::ReviewRequest,
"milestone_change" => ActivityEventType::MilestoneChange,
_ => ActivityEventType::Note,
}
}
/// 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).
fn build_project_clause_at(column: &str, project_ids: &[i64], start_idx: usize) -> String {
match project_ids.len() {
0 => String::new(),
1 => format!("AND {column} = ?{start_idx}"),
n => {
let placeholders: Vec<String> = (0..n).map(|i| format!("?{}", start_idx + i)).collect();
format!("AND {column} IN ({})", placeholders.join(","))
}
}
}
/// Convenience: project clause starting at param index 2 (after username at ?1).
fn build_project_clause(column: &str, project_ids: &[i64]) -> String {
build_project_clause_at(column, project_ids, 2)
}
/// Build the parameter vector: username first, then project IDs.
fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::types::ToSql>> {
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(username.to_string()));
for &pid in project_ids {
params.push(Box::new(pid));
}
params
}
/// Populate labels for issues via cached per-item queries.
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
if issues.is_empty() {
return Ok(());
}
for issue in issues.iter_mut() {
let mut stmt = conn.prepare_cached(
"SELECT l.name FROM labels l
JOIN issue_labels il ON l.id = il.label_id
JOIN issues i ON il.issue_id = i.id
JOIN projects p ON i.project_id = p.id
WHERE i.iid = ?1 AND p.path_with_namespace = ?2
ORDER BY l.name",
)?;
let labels: Vec<String> = stmt
.query_map(rusqlite::params![issue.iid, issue.project_path], |row| {
row.get(0)
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
issue.labels = labels;
}
Ok(())
}
/// Populate labels for MRs via cached per-item queries.
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
if mrs.is_empty() {
return Ok(());
}
for mr in mrs.iter_mut() {
let mut stmt = conn.prepare_cached(
"SELECT l.name FROM labels l
JOIN mr_labels ml ON l.id = ml.label_id
JOIN merge_requests m ON ml.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
WHERE m.iid = ?1 AND p.path_with_namespace = ?2
ORDER BY l.name",
)?;
let labels: Vec<String> = stmt
.query_map(rusqlite::params![mr.iid, mr.project_path], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
mr.labels = labels;
}
Ok(())
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "me_tests.rs"]
mod tests;

View File

@@ -0,0 +1,463 @@
use crate::cli::render::{self, GlyphMode, Icons, LoreRenderer, Theme};
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
};
// ─── Glyph Mode Helper ──────────────────────────────────────────────────────
/// Get the current glyph mode, defaulting to Unicode if renderer not initialized.
fn glyph_mode() -> GlyphMode {
LoreRenderer::try_get().map_or(GlyphMode::Unicode, LoreRenderer::glyph_mode)
}
// ─── Attention Icons ─────────────────────────────────────────────────────────
/// Return the attention icon for the current glyph mode.
fn attention_icon(state: &AttentionState) -> &'static str {
let mode = glyph_mode();
match state {
AttentionState::NeedsAttention => match mode {
GlyphMode::Nerd => "\u{f0f3}", // bell
GlyphMode::Unicode => "\u{25c6}", // diamond
GlyphMode::Ascii => "[!]",
},
AttentionState::NotStarted => match mode {
GlyphMode::Nerd => "\u{f005}", // star
GlyphMode::Unicode => "\u{2605}", // black star
GlyphMode::Ascii => "[*]",
},
AttentionState::AwaitingResponse => match mode {
GlyphMode::Nerd => "\u{f017}", // clock
GlyphMode::Unicode => "\u{25f7}", // white circle with upper right quadrant
GlyphMode::Ascii => "[~]",
},
AttentionState::Stale => match mode {
GlyphMode::Nerd => "\u{f54c}", // skull
GlyphMode::Unicode => "\u{2620}", // skull and crossbones
GlyphMode::Ascii => "[x]",
},
AttentionState::NotReady => match mode {
GlyphMode::Nerd => "\u{f040}", // pencil
GlyphMode::Unicode => "\u{270e}", // lower right pencil
GlyphMode::Ascii => "[D]",
},
}
}
/// Style for an attention state.
fn attention_style(state: &AttentionState) -> lipgloss::Style {
match state {
AttentionState::NeedsAttention => Theme::warning(),
AttentionState::NotStarted => Theme::info(),
AttentionState::AwaitingResponse | AttentionState::Stale => Theme::dim(),
AttentionState::NotReady => Theme::state_draft(),
}
}
/// Render the styled attention icon for an item.
fn styled_attention(state: &AttentionState) -> String {
let icon = attention_icon(state);
attention_style(state).render(icon)
}
// ─── Event Badges ────────────────────────────────────────────────────────────
/// Render an activity event badge (colored).
fn event_badge(event_type: &ActivityEventType) -> String {
let mode = glyph_mode();
let (label, style) = match event_type {
ActivityEventType::Note => ("note", Theme::info()),
ActivityEventType::StatusChange => ("status", Theme::warning()),
ActivityEventType::LabelChange => ("label", Theme::accent()),
ActivityEventType::Assign | ActivityEventType::Unassign => ("assign", Theme::success()),
ActivityEventType::ReviewRequest => ("assign", Theme::success()),
ActivityEventType::MilestoneChange => ("milestone", accent_magenta()),
};
match mode {
GlyphMode::Ascii => style.render(&format!("[{label}]")),
_ => {
// For nerd/unicode, use colored bg with dark text where possible.
// lipgloss background support is limited, so we use colored text as a
// practical fallback that still provides the visual distinction.
style.render(&format!(" {label} "))
}
}
}
/// Magenta accent for milestone badges.
fn accent_magenta() -> lipgloss::Style {
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
lipgloss::Style::new().foreground("#d946ef")
} else {
lipgloss::Style::new()
}
}
// ─── Summary Header ─────────────────────────────────────────────────────────
/// Print the summary header with counts and attention legend (Task #14).
pub fn print_summary_header(summary: &MeSummary, username: &str) {
println!();
println!(
"{}",
Theme::bold().render(&format!(
"{} {} -- Personal Dashboard",
Icons::user(),
username,
))
);
println!("{}", "\u{2500}".repeat(60));
// Counts line
let needs = if summary.needs_attention_count > 0 {
Theme::warning().render(&format!("{} need attention", summary.needs_attention_count))
} else {
Theme::dim().render("0 need attention")
};
println!(
" {} projects {} issues {} authored MRs {} reviewing MRs {}",
summary.project_count,
summary.open_issue_count,
summary.authored_mr_count,
summary.reviewing_mr_count,
needs,
);
// Attention legend
print_attention_legend();
}
/// Print the attention icon legend.
fn print_attention_legend() {
println!();
let states = [
(AttentionState::NeedsAttention, "needs attention"),
(AttentionState::NotStarted, "not started"),
(AttentionState::AwaitingResponse, "awaiting response"),
(AttentionState::Stale, "stale (30d+)"),
(AttentionState::NotReady, "draft (not ready)"),
];
let legend: Vec<String> = states
.iter()
.map(|(state, label)| format!("{} {}", styled_attention(state), Theme::dim().render(label)))
.collect();
println!(" {}", legend.join(" "));
}
// ─── Open Issues Section ─────────────────────────────────────────────────────
/// Print the open issues section (Task #15).
pub fn print_issues_section(issues: &[MeIssue], single_project: bool) {
if issues.is_empty() {
println!("{}", render::section_divider("Open Issues (0)"));
println!(
" {}",
Theme::dim().render("No open issues assigned to you.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Open Issues ({})", issues.len()))
);
for issue in issues {
let attn = styled_attention(&issue.attention_state);
let ref_str = format!("#{}", issue.iid);
let status = issue
.status_name
.as_deref()
.map(|s| format!(" [{s}]"))
.unwrap_or_default();
let time = render::format_relative_time(issue.updated_at);
// Line 1: attention icon, issue ref, title, status, relative time
println!(
" {} {} {}{} {}",
attn,
Theme::issue_ref().render(&ref_str),
render::truncate(&issue.title, 40),
Theme::dim().render(&status),
Theme::dim().render(&time),
);
// Line 2: project path (suppressed in single-project mode)
if !single_project {
println!(" {}", Theme::dim().render(&issue.project_path),);
}
}
}
// ─── MR Sections ─────────────────────────────────────────────────────────────
/// Print the authored MRs section (Task #16).
pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
if mrs.is_empty() {
println!("{}", render::section_divider("Authored MRs (0)"));
println!(
" {}",
Theme::dim().render("No open MRs authored by you.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Authored MRs ({})", mrs.len()))
);
for mr in mrs {
let attn = styled_attention(&mr.attention_state);
let ref_str = format!("!{}", mr.iid);
let draft = if mr.draft {
Theme::state_draft().render(" [draft]")
} else {
String::new()
};
let merge_status = mr
.detailed_merge_status
.as_deref()
.filter(|s| !s.is_empty() && *s != "not_open")
.map(|s| format!(" ({s})"))
.unwrap_or_default();
let time = render::format_relative_time(mr.updated_at);
// Line 1: attention, MR ref, title, draft, merge status, time
println!(
" {} {} {}{}{} {}",
attn,
Theme::mr_ref().render(&ref_str),
render::truncate(&mr.title, 35),
draft,
Theme::dim().render(&merge_status),
Theme::dim().render(&time),
);
// Line 2: project path
if !single_project {
println!(" {}", Theme::dim().render(&mr.project_path),);
}
}
}
/// Print the reviewing MRs section (Task #16).
pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) {
if mrs.is_empty() {
println!("{}", render::section_divider("Reviewing MRs (0)"));
println!(
" {}",
Theme::dim().render("No open MRs awaiting your review.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Reviewing MRs ({})", mrs.len()))
);
for mr in mrs {
let attn = styled_attention(&mr.attention_state);
let ref_str = format!("!{}", mr.iid);
let author = mr
.author_username
.as_deref()
.map(|a| format!(" by {}", Theme::username().render(&format!("@{a}"))))
.unwrap_or_default();
let draft = if mr.draft {
Theme::state_draft().render(" [draft]")
} else {
String::new()
};
let time = render::format_relative_time(mr.updated_at);
// Line 1: attention, MR ref, title, author, draft, time
println!(
" {} {} {}{}{} {}",
attn,
Theme::mr_ref().render(&ref_str),
render::truncate(&mr.title, 30),
author,
draft,
Theme::dim().render(&time),
);
// Line 2: project path
if !single_project {
println!(" {}", Theme::dim().render(&mr.project_path),);
}
}
}
// ─── Activity Feed ───────────────────────────────────────────────────────────
/// Print the activity feed section (Task #17).
pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool) {
if events.is_empty() {
println!("{}", render::section_divider("Activity (0)"));
println!(
" {}",
Theme::dim().render("No recent activity on your items.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Activity ({})", events.len()))
);
for event in events {
let badge = event_badge(&event.event_type);
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
let time = render::format_relative_time_compact(event.timestamp);
let actor_str = if event.is_own {
Theme::dim().render(&format!(
"{}(you)",
event
.actor
.as_deref()
.map(|a| format!("@{a} "))
.unwrap_or_default()
))
} else {
event
.actor
.as_deref()
.map(|a| Theme::username().render(&format!("@{a}")))
.unwrap_or_default()
};
let summary = render::truncate(&event.summary, 40);
// Dim own actions
let summary_styled = if event.is_own {
Theme::dim().render(&summary)
} else {
summary
};
// Line 1: badge, entity ref, summary, actor, time
println!(
" {badge} {entity_ref:7} {summary_styled} {actor_str} {}",
Theme::dim().render(&time),
);
// Line 2: project path (if multi-project) + body preview for notes
if !single_project {
println!(" {}", Theme::dim().render(&event.project_path),);
}
if let Some(preview) = &event.body_preview
&& !preview.is_empty()
{
let truncated = render::truncate(preview, 60);
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")),);
}
}
}
/// Format an entity reference (#N for issues, !N for MRs).
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
match entity_type {
"issue" => {
let s = format!("#{iid}");
Theme::issue_ref().render(&s)
}
"mr" => {
let s = format!("!{iid}");
Theme::mr_ref().render(&s)
}
_ => format!("{entity_type}:{iid}"),
}
}
// ─── Full Dashboard ──────────────────────────────────────────────────────────
/// Render the complete human-mode dashboard.
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
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);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
print_activity_section(&dashboard.activity, single_project);
println!();
}
/// Render a filtered dashboard (only requested sections).
pub fn print_me_dashboard_filtered(
dashboard: &MeDashboard,
single_project: bool,
show_issues: bool,
show_mrs: bool,
show_activity: bool,
) {
print_summary_header(&dashboard.summary, &dashboard.username);
if show_issues {
print_issues_section(&dashboard.open_issues, single_project);
}
if show_mrs {
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
}
if show_activity {
print_activity_section(&dashboard.activity, single_project);
}
println!();
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attention_icon_returns_nonempty_for_all_states() {
let states = [
AttentionState::NeedsAttention,
AttentionState::NotStarted,
AttentionState::AwaitingResponse,
AttentionState::Stale,
AttentionState::NotReady,
];
for state in &states {
assert!(!attention_icon(state).is_empty(), "empty for {state:?}");
}
}
#[test]
fn format_entity_ref_issue() {
let result = format_entity_ref("issue", 42);
assert!(result.contains("42"), "got: {result}");
}
#[test]
fn format_entity_ref_mr() {
let result = format_entity_ref("mr", 99);
assert!(result.contains("99"), "got: {result}");
}
#[test]
fn event_badge_returns_nonempty_for_all_types() {
let types = [
ActivityEventType::Note,
ActivityEventType::StatusChange,
ActivityEventType::LabelChange,
ActivityEventType::Assign,
ActivityEventType::Unassign,
ActivityEventType::ReviewRequest,
ActivityEventType::MilestoneChange,
];
for t in &types {
assert!(!event_badge(t).is_empty(), "empty for {t:?}");
}
}
}

View File

@@ -0,0 +1,334 @@
use serde::Serialize;
use crate::cli::robot::RobotMeta;
use crate::core::time::ms_to_iso;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
};
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
/// Print the full me dashboard as robot-mode JSON.
pub fn print_me_json(
dashboard: &MeDashboard,
elapsed_ms: u64,
fields: Option<&[String]>,
) -> crate::core::error::Result<()> {
let envelope = MeJsonEnvelope {
ok: true,
data: MeDataJson::from_dashboard(dashboard),
meta: RobotMeta { elapsed_ms },
};
let mut value = serde_json::to_value(&envelope)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
// Apply --fields filtering (Task #19)
if let Some(f) = fields {
let expanded = crate::cli::robot::expand_fields_preset(f, "me_items");
// Filter all item arrays
for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] {
crate::cli::robot::filter_fields(&mut value, key, &expanded);
}
// Activity gets its own minimal preset
let activity_expanded = crate::cli::robot::expand_fields_preset(f, "me_activity");
crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded);
}
let json = serde_json::to_string(&value)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
println!("{json}");
Ok(())
}
// ─── JSON Envelope ───────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MeJsonEnvelope {
ok: bool,
data: MeDataJson,
meta: RobotMeta,
}
#[derive(Serialize)]
struct MeDataJson {
username: String,
since_iso: Option<String>,
summary: SummaryJson,
open_issues: Vec<IssueJson>,
open_mrs_authored: Vec<MrJson>,
reviewing_mrs: Vec<MrJson>,
activity: Vec<ActivityJson>,
}
impl MeDataJson {
fn from_dashboard(d: &MeDashboard) -> Self {
Self {
username: d.username.clone(),
since_iso: d.since_ms.map(ms_to_iso),
summary: SummaryJson::from(&d.summary),
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(),
activity: d.activity.iter().map(ActivityJson::from).collect(),
}
}
}
// ─── Summary ─────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct SummaryJson {
project_count: usize,
open_issue_count: usize,
authored_mr_count: usize,
reviewing_mr_count: usize,
needs_attention_count: usize,
}
impl From<&MeSummary> for SummaryJson {
fn from(s: &MeSummary) -> Self {
Self {
project_count: s.project_count,
open_issue_count: s.open_issue_count,
authored_mr_count: s.authored_mr_count,
reviewing_mr_count: s.reviewing_mr_count,
needs_attention_count: s.needs_attention_count,
}
}
}
// ─── Issue ───────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct IssueJson {
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
status_name: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeIssue> for IssueJson {
fn from(i: &MeIssue) -> Self {
Self {
project: i.project_path.clone(),
iid: i.iid,
title: i.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&i.attention_state),
status_name: i.status_name.clone(),
labels: i.labels.clone(),
updated_at_iso: ms_to_iso(i.updated_at),
web_url: i.web_url.clone(),
}
}
}
// ─── MR ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MrJson {
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
draft: bool,
detailed_merge_status: Option<String>,
author_username: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeMr> for MrJson {
fn from(m: &MeMr) -> Self {
Self {
project: m.project_path.clone(),
iid: m.iid,
title: m.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&m.attention_state),
draft: m.draft,
detailed_merge_status: m.detailed_merge_status.clone(),
author_username: m.author_username.clone(),
labels: m.labels.clone(),
updated_at_iso: ms_to_iso(m.updated_at),
web_url: m.web_url.clone(),
}
}
}
// ─── Activity ────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ActivityJson {
timestamp_iso: String,
event_type: String,
entity_type: String,
entity_iid: i64,
project: String,
actor: Option<String>,
is_own: bool,
summary: String,
body_preview: Option<String>,
}
impl From<&MeActivityEvent> for ActivityJson {
fn from(e: &MeActivityEvent) -> Self {
Self {
timestamp_iso: ms_to_iso(e.timestamp),
event_type: event_type_str(&e.event_type),
entity_type: e.entity_type.clone(),
entity_iid: e.entity_iid,
project: e.project_path.clone(),
actor: e.actor.clone(),
is_own: e.is_own,
summary: e.summary.clone(),
body_preview: e.body_preview.clone(),
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/// Convert `AttentionState` to its programmatic string representation.
fn attention_state_str(state: &AttentionState) -> String {
match state {
AttentionState::NeedsAttention => "needs_attention",
AttentionState::NotStarted => "not_started",
AttentionState::AwaitingResponse => "awaiting_response",
AttentionState::Stale => "stale",
AttentionState::NotReady => "not_ready",
}
.to_string()
}
/// Convert `ActivityEventType` to its programmatic string representation.
fn event_type_str(event_type: &ActivityEventType) -> String {
match event_type {
ActivityEventType::Note => "note",
ActivityEventType::StatusChange => "status_change",
ActivityEventType::LabelChange => "label_change",
ActivityEventType::Assign => "assign",
ActivityEventType::Unassign => "unassign",
ActivityEventType::ReviewRequest => "review_request",
ActivityEventType::MilestoneChange => "milestone_change",
}
.to_string()
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attention_state_str_all_variants() {
assert_eq!(
attention_state_str(&AttentionState::NeedsAttention),
"needs_attention"
);
assert_eq!(
attention_state_str(&AttentionState::NotStarted),
"not_started"
);
assert_eq!(
attention_state_str(&AttentionState::AwaitingResponse),
"awaiting_response"
);
assert_eq!(attention_state_str(&AttentionState::Stale), "stale");
assert_eq!(attention_state_str(&AttentionState::NotReady), "not_ready");
}
#[test]
fn event_type_str_all_variants() {
assert_eq!(event_type_str(&ActivityEventType::Note), "note");
assert_eq!(
event_type_str(&ActivityEventType::StatusChange),
"status_change"
);
assert_eq!(
event_type_str(&ActivityEventType::LabelChange),
"label_change"
);
assert_eq!(event_type_str(&ActivityEventType::Assign), "assign");
assert_eq!(event_type_str(&ActivityEventType::Unassign), "unassign");
assert_eq!(
event_type_str(&ActivityEventType::ReviewRequest),
"review_request"
);
assert_eq!(
event_type_str(&ActivityEventType::MilestoneChange),
"milestone_change"
);
}
#[test]
fn issue_json_from_me_issue() {
let issue = MeIssue {
iid: 42,
title: "Fix auth bug".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::NeedsAttention,
status_name: Some("In progress".to_string()),
labels: vec!["bug".to_string()],
updated_at: 1_700_000_000_000,
web_url: Some("https://gitlab.com/group/repo/-/issues/42".to_string()),
};
let json = IssueJson::from(&issue);
assert_eq!(json.iid, 42);
assert_eq!(json.attention_state, "needs_attention");
assert_eq!(json.state, "opened");
assert_eq!(json.status_name, Some("In progress".to_string()));
}
#[test]
fn mr_json_from_me_mr() {
let mr = MeMr {
iid: 99,
title: "Add feature".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::AwaitingResponse,
draft: true,
detailed_merge_status: Some("mergeable".to_string()),
author_username: Some("alice".to_string()),
labels: vec![],
updated_at: 1_700_000_000_000,
web_url: None,
};
let json = MrJson::from(&mr);
assert_eq!(json.iid, 99);
assert_eq!(json.attention_state, "awaiting_response");
assert!(json.draft);
assert_eq!(json.author_username, Some("alice".to_string()));
}
#[test]
fn activity_json_from_event() {
let event = MeActivityEvent {
timestamp: 1_700_000_000_000,
event_type: ActivityEventType::Note,
entity_type: "issue".to_string(),
entity_iid: 42,
project_path: "group/repo".to_string(),
actor: Some("bob".to_string()),
is_own: false,
summary: "Added a comment".to_string(),
body_preview: Some("This looks good".to_string()),
};
let json = ActivityJson::from(&event);
assert_eq!(json.event_type, "note");
assert_eq!(json.entity_iid, 42);
assert!(!json.is_own);
assert_eq!(json.body_preview, Some("This looks good".to_string()));
}
}

View File

@@ -0,0 +1,98 @@
// ─── Dashboard Types ─────────────────────────────────────────────────────────
//
// Data structs for the `lore me` personal dashboard.
// These are populated by query functions and consumed by renderers.
/// Attention state for a work item (AC-4.4).
/// Ordered by display priority (first = most urgent).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AttentionState {
/// Others commented after me (or I never engaged but others have)
NeedsAttention = 0,
/// Zero non-system notes from anyone
NotStarted = 1,
/// My latest note >= all others' latest notes
AwaitingResponse = 2,
/// Latest note from anyone is older than 30 days
Stale = 3,
/// MR-only: draft with no reviewers
NotReady = 4,
}
/// Activity event type for the feed (AC-5.4, AC-6.4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActivityEventType {
/// Human comment (non-system note)
Note,
/// State change (opened/closed/reopened/merged)
StatusChange,
/// Label added or removed
LabelChange,
/// Assignment event
Assign,
/// Unassignment event
Unassign,
/// Review request
ReviewRequest,
/// Milestone change
MilestoneChange,
}
/// Summary counts for the dashboard header (AC-5.5).
pub struct MeSummary {
pub project_count: usize,
pub open_issue_count: usize,
pub authored_mr_count: usize,
pub reviewing_mr_count: usize,
pub needs_attention_count: usize,
}
/// An open issue assigned to the user (AC-5.1).
pub struct MeIssue {
pub iid: i64,
pub title: String,
pub project_path: String,
pub attention_state: AttentionState,
pub status_name: Option<String>,
pub labels: Vec<String>,
pub updated_at: i64,
pub web_url: Option<String>,
}
/// An open MR authored by or reviewing for the user (AC-5.2, AC-5.3).
pub struct MeMr {
pub iid: i64,
pub title: String,
pub project_path: String,
pub attention_state: AttentionState,
pub draft: bool,
pub detailed_merge_status: Option<String>,
pub author_username: Option<String>,
pub labels: Vec<String>,
pub updated_at: i64,
pub web_url: Option<String>,
}
/// An activity event in the feed (AC-5.4).
pub struct MeActivityEvent {
pub timestamp: i64,
pub event_type: ActivityEventType,
pub entity_type: String,
pub entity_iid: i64,
pub project_path: String,
pub actor: Option<String>,
pub is_own: bool,
pub summary: String,
pub body_preview: Option<String>,
}
/// The complete dashboard result.
pub struct MeDashboard {
pub username: String,
pub since_ms: Option<i64>,
pub summary: MeSummary,
pub open_issues: Vec<MeIssue>,
pub open_mrs_authored: Vec<MeMr>,
pub reviewing_mrs: Vec<MeMr>,
pub activity: Vec<MeActivityEvent>,
}

View File

@@ -10,6 +10,7 @@ pub mod generate_docs;
pub mod ingest;
pub mod init;
pub mod list;
pub mod me;
pub mod search;
pub mod show;
pub mod stats;
@@ -46,6 +47,7 @@ pub use list::{
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
print_list_notes, print_list_notes_json, query_notes, run_list_issues, run_list_mrs,
};
pub use me::run_me;
pub use search::{
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
};

View File

@@ -261,6 +261,9 @@ pub enum Commands {
/// People intelligence: experts, workload, active discussions, overlap
Who(WhoArgs),
/// Personal work dashboard: open issues, authored/reviewing MRs, activity
Me(MeArgs),
/// Show MRs that touched a file, with linked discussions
#[command(name = "file-history")]
FileHistory(FileHistoryArgs),
@@ -1050,6 +1053,57 @@ pub struct WhoArgs {
pub all_history: bool,
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore me # Full dashboard (default project or all)
lore me --issues # Issues section only
lore me --mrs # MRs section only
lore me --activity # Activity feed only
lore me --all # All synced projects
lore me --since 2d # Activity window (default: 30d)
lore me --project group/repo # Scope to one project
lore me --user jdoe # Override configured username")]
pub struct MeArgs {
/// Show open issues section
#[arg(long, help_heading = "Sections")]
pub issues: bool,
/// Show authored + reviewing MRs section
#[arg(long, help_heading = "Sections")]
pub mrs: bool,
/// Show activity feed section
#[arg(long, help_heading = "Sections")]
pub activity: bool,
/// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section.
#[arg(long, help_heading = "Filters")]
pub since: Option<String>,
/// Scope to a project (supports fuzzy matching)
#[arg(short = 'p', long, help_heading = "Filters", conflicts_with = "all")]
pub project: Option<String>,
/// Show all synced projects (overrides default_project)
#[arg(long, help_heading = "Filters", conflicts_with = "project")]
pub all: bool,
/// Override configured username
#[arg(long = "user", help_heading = "Filters")]
pub user: Option<String>,
/// Select output fields (comma-separated, or 'minimal' preset)
#[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>,
}
impl MeArgs {
/// Returns true if no section flags were passed (show all sections).
pub fn show_all_sections(&self) -> bool {
!self.issues && !self.mrs && !self.activity
}
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore file-history src/main.rs # MRs that touched this file

View File

@@ -263,6 +263,11 @@ impl LoreRenderer {
.expect("LoreRenderer::init must be called before get")
}
/// Try to get the global renderer. Returns `None` if `init` hasn't been called.
pub fn try_get() -> Option<&'static LoreRenderer> {
RENDERER.get()
}
/// Whether color output is enabled.
pub fn colors_enabled(&self) -> bool {
self.colors

View File

@@ -68,6 +68,14 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
.iter()
.map(|s| (*s).to_string())
.collect(),
"me_items" => ["iid", "title", "attention_state", "updated_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"me_activity" => ["timestamp_iso", "event_type", "entity_iid", "actor"]
.iter()
.map(|s| (*s).to_string())
.collect(),
_ => fields.to_vec(),
}
} else {

View File

@@ -16,6 +16,10 @@ pub struct GitLabConfig {
/// Optional stored token (env var takes priority when both are set).
#[serde(default)]
pub token: Option<String>,
/// Optional GitLab username for `lore me` personal dashboard.
#[serde(default)]
pub username: Option<String>,
}
impl GitLabConfig {
@@ -570,6 +574,7 @@ mod tests {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: None,
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),
@@ -594,6 +599,7 @@ mod tests {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: None,
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),
@@ -615,6 +621,7 @@ mod tests {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: None,
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),
@@ -837,6 +844,7 @@ mod tests {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: env_var.to_string(),
token: token.map(ToString::to_string),
username: None,
}
}
@@ -907,4 +915,39 @@ mod tests {
let cfg = gitlab_cfg_with_env(VAR, None);
assert!(cfg.resolve_token().is_err());
}
// ── gitlab.username ─────────────────────────────────────────────
#[test]
fn test_config_loads_with_username() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.json");
let config = r#"{
"gitlab": {
"baseUrl": "https://gitlab.example.com",
"tokenEnvVar": "GITLAB_TOKEN",
"username": "jdoe"
},
"projects": [{ "path": "group/project" }]
}"#;
fs::write(&path, config).unwrap();
let cfg = Config::load_from_path(&path).unwrap();
assert_eq!(cfg.gitlab.username.as_deref(), Some("jdoe"));
}
#[test]
fn test_config_loads_without_username() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.json");
let config = r#"{
"gitlab": {
"baseUrl": "https://gitlab.example.com",
"tokenEnvVar": "GITLAB_TOKEN"
},
"projects": [{ "path": "group/project" }]
}"#;
fs::write(&path, config).unwrap();
let cfg = Config::load_from_path(&path).unwrap();
assert_eq!(cfg.gitlab.username, None);
}
}

View File

@@ -30,6 +30,7 @@ fn test_config() -> Config {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: None,
},
projects: vec![ProjectConfig {
path: "group/repo".to_string(),
@@ -167,7 +168,6 @@ fn test_ingest_issue_by_iid_upserts_and_marks_dirty() {
let result = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
assert!(!result.dirty_source_keys.is_empty());
@@ -199,7 +199,6 @@ fn test_toctou_skips_stale_issue() {
// Second ingest with same timestamp should be skipped
let r2 = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
assert!(r2.skipped_stale);
assert!(r2.skipped_stale);
assert!(r2.dirty_source_keys.is_empty());
// No new dirty mark
@@ -223,7 +222,6 @@ fn test_toctou_allows_newer_issue() {
let result = ingest_issue_by_iid(&conn, &config, 1, &issue_t2).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
}
#[test]
@@ -271,7 +269,6 @@ fn test_ingest_mr_by_iid_upserts_and_marks_dirty() {
let result = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
assert!(!result.dirty_source_keys.is_empty());
@@ -298,7 +295,6 @@ fn test_toctou_skips_stale_mr() {
let r2 = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
assert!(r2.skipped_stale);
assert!(r2.skipped_stale);
assert!(r2.dirty_source_keys.is_empty());
}
@@ -316,7 +312,6 @@ fn test_toctou_allows_newer_mr() {
let result = ingest_mr_by_iid(&conn, &config, 1, &mr_t2).unwrap();
assert!(!result.skipped_stale);
assert!(!result.skipped_stale);
}
#[test]

View File

@@ -25,15 +25,15 @@ use lore::cli::commands::{
print_who_json, query_notes, run_auth_test, run_count, run_count_events, run_cron_install,
run_cron_status, run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history,
run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline,
run_token_set, run_token_show, run_who,
run_me, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status,
run_timeline, run_token_set, run_token_show, run_who,
};
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas};
use lore::cli::{
Cli, Commands, CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs,
IngestArgs, IssuesArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
TokenAction, TokenArgs, TraceArgs, WhoArgs,
IngestArgs, IssuesArgs, MeArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs,
TimelineArgs, TokenAction, TokenArgs, TraceArgs, WhoArgs,
};
use lore::core::db::{
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
@@ -202,6 +202,7 @@ async fn main() {
handle_timeline(cli.config.as_deref(), args, robot_mode).await
}
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
Some(Commands::Me(args)) => handle_me(cli.config.as_deref(), args, robot_mode),
Some(Commands::FileHistory(args)) => {
handle_file_history(cli.config.as_deref(), args, robot_mode)
}
@@ -3182,6 +3183,16 @@ fn handle_who(
Ok(())
}
fn handle_me(
config_override: Option<&str>,
args: MeArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
run_me(&config, &args, robot_mode)?;
Ok(())
}
async fn handle_drift(
config_override: Option<&str>,
entity_type: &str,