Compare commits
3 Commits
34680f0087
...
597095a283
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597095a283 | ||
|
|
d0e88abe85 | ||
|
|
cb6894798e |
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-2fc
|
||||
bd-1tv8
|
||||
|
||||
13
.liquid-mail.toml
Normal file
13
.liquid-mail.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
# Liquid Mail config (TOML)
|
||||
#
|
||||
# Prefer env vars for secrets:
|
||||
# LIQUID_MAIL_HONCHO_API_KEY
|
||||
# LIQUID_MAIL_HONCHO_WORKSPACE_ID
|
||||
#
|
||||
[honcho]
|
||||
api_key = "hch-v3-pmx23gk9k60xlqffpxpyjj8pywnxkpjkic9bdygx21iydvyxbeialioz5ehhcp1r"
|
||||
# workspace_id is optional.
|
||||
# If omitted, Liquid Mail defaults it to the repo name (git root folder name).
|
||||
# Honcho uses get-or-create semantics for workspaces, so it will be created on first use.
|
||||
# workspace_id = "my-repo"
|
||||
base_url = "https://api.honcho.dev"
|
||||
96
AGENTS.md
96
AGENTS.md
@@ -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
248
CLAUDE.md
@@ -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:
|
||||
- 4–50 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 -->
|
||||
|
||||
290
docs/lore-me-spec.md
Normal file
290
docs/lore-me-spec.md
Normal 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 ...
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
749
src/cli/commands/me/me_tests.rs
Normal file
749
src/cli/commands/me/me_tests.rs
Normal 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
424
src/cli/commands/me/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
560
src/cli/commands/me/queries.rs
Normal file
560
src/cli/commands/me/queries.rs
Normal 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;
|
||||
463
src/cli/commands/me/render_human.rs
Normal file
463
src/cli/commands/me/render_human.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
334
src/cli/commands/me/render_robot.rs
Normal file
334
src/cli/commands/me/render_robot.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
98
src/cli/commands/me/types.rs
Normal file
98
src/cli/commands/me/types.rs
Normal 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>,
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
19
src/main.rs
19
src/main.rs
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user