Compare commits
92 Commits
cli-imp
...
5fd1ce6905
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd1ce6905 | ||
|
|
b67bb8754c | ||
|
|
3f38b3fda7 | ||
|
|
439c20e713 | ||
|
|
fd0a40b181 | ||
|
|
b2811b5e45 | ||
|
|
2d2e470621 | ||
|
|
23efb15599 | ||
|
|
a45c37c7e4 | ||
|
|
8657e10822 | ||
|
|
7fdeafa330 | ||
|
|
0fe3737035 | ||
|
|
87bdbda468 | ||
|
|
ed987c8f71 | ||
|
|
ce5621f3ed | ||
|
|
eac640225f | ||
|
|
c5843bd823 | ||
|
|
f9e7913232 | ||
|
|
6e487532aa | ||
|
|
7e9a23cc0f | ||
|
|
71d07c28d8 | ||
|
|
f4de6feaa2 | ||
|
|
ec0aaaf77c | ||
|
|
9c1a9bfe5d | ||
|
|
a5c2589c7d | ||
|
|
8fdb366b6d | ||
|
|
53b093586b | ||
|
|
9ec1344945 | ||
|
|
ea6e45e43f | ||
|
|
30ed02c694 | ||
|
|
a4df8e5444 | ||
|
|
53ce20595b | ||
|
|
1808a4da8e | ||
|
|
7d032833a2 | ||
|
|
097249f4e6 | ||
|
|
8442bcf367 | ||
|
|
c0ca501662 | ||
|
|
c953d8e519 | ||
|
|
63bd58c9b4 | ||
|
|
714c8c2623 | ||
|
|
171260a772 | ||
|
|
a1bca10408 | ||
|
|
491dc52864 | ||
|
|
b9063aa17a | ||
|
|
fc0d9cb1d3 | ||
|
|
c8b47bf8f8 | ||
|
|
a570327a6b | ||
|
|
eef73decb5 | ||
|
|
bb6660178c | ||
|
|
64e73b1cab | ||
|
|
361757568f | ||
|
|
8572f6cc04 | ||
|
|
d0744039ef | ||
|
|
4b372dfb38 | ||
|
|
af8fc4af76 | ||
|
|
96b288ccdd | ||
|
|
d710403567 | ||
|
|
ebf64816c9 | ||
|
|
450951dee1 | ||
|
|
81f049a7fa | ||
|
|
dd00a2b840 | ||
|
|
c6a5461d41 | ||
|
|
a7f86b26e4 | ||
|
|
5ee8b0841c | ||
|
|
7062a3f1fd | ||
|
|
159c490ad7 | ||
|
|
e0041ed4d9 | ||
|
|
a34751bd47 | ||
|
|
0aecbf33c0 | ||
|
|
c10471ddb9 | ||
|
|
cbce4c9f59 | ||
|
|
94435c37f0 | ||
|
|
59f65b127a | ||
|
|
f36e900570 | ||
|
|
e2efc61beb | ||
|
|
2da1a228b3 | ||
|
|
0e65202778 | ||
|
|
f439c42b3d | ||
|
|
4f3ec72923 | ||
|
|
e6771709f1 | ||
|
|
8c86b0dfd7 | ||
|
|
6e55b2470d | ||
|
|
b05922d60b | ||
|
|
11fe02fac9 | ||
|
|
48fbd4bfdb | ||
|
|
9786ef27f5 | ||
|
|
7e0e6a91f2 | ||
|
|
5c2df3df3b | ||
|
|
94c8613420 | ||
|
|
ad4dd6e855 | ||
|
|
83cd16c918 | ||
|
|
fda9cd8835 |
295
.beads/.br_history/issues.20260212_171003.jsonl
Normal file
295
.beads/.br_history/issues.20260212_171003.jsonl
Normal file
File diff suppressed because one or more lines are too long
304
.beads/.br_history/issues.20260212_171103.jsonl
Normal file
304
.beads/.br_history/issues.20260212_171103.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-xsgw
|
||||
bd-8con
|
||||
|
||||
99
.claude/plan.md
Normal file
99
.claude/plan.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Plan: Add Colors to Sync Command Output
|
||||
|
||||
## Current State
|
||||
|
||||
The sync output has three layers, each needing color treatment:
|
||||
|
||||
### Layer 1: Stage Lines (during sync)
|
||||
```
|
||||
✓ Issues 10 issues from 2 projects 4.2s
|
||||
✓ Status 3 statuses updated · 5 seen 4.2s
|
||||
vs/typescript-code 2 issues · 1 statuses updated
|
||||
✓ MRs 5 merge requests from 2 projects 12.3s
|
||||
vs/python-code 3 MRs · 10 discussions
|
||||
✓ Docs 1,200 documents generated 8.1s
|
||||
✓ Embed 3,400 chunks embedded 45.2s
|
||||
```
|
||||
|
||||
**What's uncolored:** icons, labels, numbers, elapsed times, sub-row project paths, failure counts in parentheses.
|
||||
|
||||
### Layer 2: Summary (after sync)
|
||||
```
|
||||
Synced 10 issues and 5 MRs in 42.3s
|
||||
120 discussions · 45 events · 12 diffs · 3 statuses updated
|
||||
1,200 docs regenerated · 3,400 embedded
|
||||
```
|
||||
|
||||
**What's already colored:** headline ("Synced" = green bold, "Sync completed with issues" = warning bold), issue/MR counts (bold), error line (red). Detail lines are all dim.
|
||||
|
||||
### Layer 3: Timing breakdown (`-t` flag)
|
||||
```
|
||||
── Timing ──────────────────────
|
||||
issues .............. 4.2s
|
||||
merge_requests ...... 12.3s
|
||||
```
|
||||
|
||||
**What's already colored:** dots (dim), time (bold), errors (red), rate limits (warning).
|
||||
|
||||
---
|
||||
|
||||
## Color Plan
|
||||
|
||||
Using only existing `Theme` methods — no new colors needed.
|
||||
|
||||
### Stage Lines (`format_stage_line` + callers in sync.rs)
|
||||
|
||||
| Element | Current | Proposed | Theme method |
|
||||
|---------|---------|----------|-------------|
|
||||
| Icon (✓/⚠) | plain | green for success, yellow for warning | `Theme::success()` / `Theme::warning()` |
|
||||
| Label ("Issues", "MRs", etc.) | plain | bold | `Theme::bold()` |
|
||||
| Numbers in summary text | plain | bold | `Theme::bold()` (just the count) |
|
||||
| Elapsed time | plain | muted gray | `Theme::timing()` |
|
||||
| Failure text in parens | plain | warning/error color | `Theme::warning()` |
|
||||
|
||||
### Sub-rows (project breakdown lines)
|
||||
|
||||
| Element | Current | Proposed |
|
||||
|---------|---------|----------|
|
||||
| Project path | dim | `Theme::muted()` (slightly brighter than dim) |
|
||||
| Counts (numbers only) | dim | `Theme::dim()` but numbers in normal weight |
|
||||
| Error/failure counts | dim | `Theme::warning()` |
|
||||
| Middle dots | dim | keep dim (they're separators, should recede) |
|
||||
|
||||
### Summary (`print_sync`)
|
||||
|
||||
| Element | Current | Proposed |
|
||||
|---------|---------|----------|
|
||||
| Issue/MR counts in headline | bold only | `Theme::info()` + bold (cyan numbers pop) |
|
||||
| Time in headline | plain | `Theme::timing()` |
|
||||
| Detail line numbers | all dim | numbers in `Theme::info()`, rest stays dim |
|
||||
| Doc line numbers | all dim | numbers in `Theme::info()`, rest stays dim |
|
||||
| "Already up to date" time | plain | `Theme::timing()` |
|
||||
|
||||
---
|
||||
|
||||
## Files to Change
|
||||
|
||||
1. **`src/cli/progress.rs`** — `format_stage_line()`: apply color to icon, bold to label, `Theme::timing()` to elapsed
|
||||
2. **`src/cli/commands/sync.rs`** —
|
||||
- Pass colored icons to `format_stage_line` / `emit_stage_line` / `emit_stage_block`
|
||||
- Color failure text in `append_failures()`
|
||||
- Color numbers and time in `print_sync()`
|
||||
- Color error/failure counts in sub-row functions (`issue_sub_rows`, `mr_sub_rows`, `status_sub_rows`)
|
||||
|
||||
## Approach
|
||||
|
||||
- `format_stage_line` already receives the icon string — color it before passing
|
||||
- Add a `color_icon` helper that applies success/warning color to the icon glyph
|
||||
- Bold the label in `format_stage_line`
|
||||
- Apply `Theme::timing()` to elapsed in `format_stage_line`
|
||||
- In `append_failures`, wrap failure text in `Theme::warning()`
|
||||
- In `print_sync`, wrap count numbers with `Theme::info().bold()`
|
||||
- In sub-row functions, apply `Theme::warning()` to error/failure parts only (keep rest dim)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No changes to robot mode (JSON output)
|
||||
- No changes to dry-run output (already reasonably colored)
|
||||
- No new Theme colors — use existing palette
|
||||
- No changes to timing breakdown (already colored)
|
||||
21
.github/workflows/roam.yml
vendored
Normal file
21
.github/workflows/roam.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Roam Code Analysis
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
jobs:
|
||||
roam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install roam-code
|
||||
- run: roam index
|
||||
- run: roam fitness
|
||||
- run: roam pr-risk --json
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,6 +31,7 @@ yarn-error.log*
|
||||
|
||||
# Local config files
|
||||
lore.config.json
|
||||
.liquid-mail.toml
|
||||
|
||||
# beads
|
||||
.bv/
|
||||
@@ -41,6 +42,9 @@ lore.config.json
|
||||
*.db-shm
|
||||
|
||||
|
||||
# Mock seed data
|
||||
tools/mock-seed/
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
||||
11
.roam/fitness.yaml
Normal file
11
.roam/fitness.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
rules:
|
||||
- name: No circular imports in core
|
||||
type: dependency
|
||||
source: "src/**"
|
||||
forbidden_target: "tests/**"
|
||||
reason: "Production code should not import test modules"
|
||||
- name: Complexity threshold
|
||||
type: metric
|
||||
metric: cognitive_complexity
|
||||
threshold: 30
|
||||
reason: "Functions above 30 cognitive complexity need refactoring"
|
||||
141
AGENTS.md
141
AGENTS.md
@@ -16,43 +16,10 @@ If I tell you to do something, even if it goes against what follows below, YOU M
|
||||
|
||||
## Version Control: jj-First (CRITICAL)
|
||||
|
||||
**ALWAYS prefer jj (Jujutsu) over git for VCS mutations** (commit, describe, rebase, push, bookmark, undo). This is a colocated repo with both `.jj/` and `.git/`. Only fall back to raw `git` for things jj cannot do (hooks, LFS, submodules, `gh` CLI interop).
|
||||
|
||||
**Exception — read-only inspection:** Use `git status`, `git diff`, `git log` instead of their jj equivalents. In a colocated repo these see accurate data, and unlike jj, they don't create operations that cause divergences when multiple agents run concurrently. See "Parallel Agent VCS Protocol" below.
|
||||
**ALWAYS prefer jj (Jujutsu) over git for all VCS operations.** This is a colocated repo with both `.jj/` and `.git/`. When instructed to use git by anything — even later in this file — use the best jj replacement commands instead. Only fall back to raw `git` for things jj cannot do (hooks, LFS, submodules, `gh` CLI interop).
|
||||
|
||||
See `~/.claude/rules/jj-vcs/` for the full command reference, translation table, revsets, patterns, and recovery recipes.
|
||||
|
||||
### Parallel Agent VCS Protocol (CRITICAL)
|
||||
|
||||
Multiple agents often run concurrently in separate terminal panes, sharing the same repo directory. This requires care because jj's auto-snapshot creates operations on EVERY command — even read-only ones like `jj status`. Concurrent jj commands fork from the same parent operation and create **divergent changes**.
|
||||
|
||||
**The rule: use git for reads, jj for writes.**
|
||||
|
||||
In a colocated repo, git reads see accurate data because jj keeps `.git/` in sync.
|
||||
|
||||
| Operation | Use | Why |
|
||||
|-----------|-----|-----|
|
||||
| Check status | `git status` | No jj operation created |
|
||||
| View diff | `git diff` | No jj operation created |
|
||||
| Browse history | `git log` | No jj operation created |
|
||||
| Commit work | `jj commit -m "msg"` | jj mutation (better UX) |
|
||||
| Update description | `jj describe -m "msg"` | jj mutation |
|
||||
| Rebase | `jj rebase -d trunk()` | jj mutation |
|
||||
| Push | `jj git push -b <name>` | jj mutation |
|
||||
| Manage bookmarks | `jj bookmark set ...` | jj mutation |
|
||||
| Undo a mistake | `jj undo` | jj mutation |
|
||||
|
||||
**NEVER run `jj status`, `jj diff`, `jj log`, or `jj show` when other agents may be active** — these trigger snapshots that cause divergences.
|
||||
|
||||
**If using Claude Code's built-in agent teams:** Only the team lead runs ANY VCS commands (git or jj). Workers only edit files via Edit/Write tools and do NOT run "Landing the Plane".
|
||||
|
||||
**Resolving divergences if they occur:**
|
||||
|
||||
```bash
|
||||
jj log -r 'divergent()' # Find divergent changes
|
||||
jj abandon <unwanted-commit-id> # Keep the version you want
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Irreversible Git & Filesystem Actions — DO NOT EVER BREAK GLASS
|
||||
@@ -160,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
|
||||
|
||||
@@ -228,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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -264,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.**
|
||||
|
||||
@@ -706,6 +623,16 @@ lore --robot generate-docs
|
||||
# Generate vector embeddings via Ollama
|
||||
lore --robot embed
|
||||
|
||||
# Personal work dashboard
|
||||
lore --robot me
|
||||
lore --robot me --issues
|
||||
lore --robot me --mrs
|
||||
lore --robot me --activity --since 7d
|
||||
lore --robot me --project group/repo
|
||||
lore --robot me --user jdoe
|
||||
lore --robot me --fields minimal
|
||||
lore --robot me --reset-cursor
|
||||
|
||||
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||
lore robot-docs
|
||||
|
||||
|
||||
953
CLAUDE.md
Normal file
953
CLAUDE.md
Normal file
@@ -0,0 +1,953 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## RULE 0 - THE FUNDAMENTAL OVERRIDE PEROGATIVE
|
||||
|
||||
If I tell you to do something, even if it goes against what follows below, YOU MUST LISTEN TO ME. I AM IN CHARGE, NOT YOU.
|
||||
|
||||
---
|
||||
|
||||
## RULE NUMBER 1: NO FILE DELETION
|
||||
|
||||
**YOU ARE NEVER ALLOWED TO DELETE A FILE WITHOUT EXPRESS PERMISSION.** Even a new file that you yourself created, such as a test code file. You have a horrible track record of deleting critically important files or otherwise throwing away tons of expensive work. As a result, you have permanently lost any and all rights to determine that a file or folder should be deleted.
|
||||
|
||||
**YOU MUST ALWAYS ASK AND RECEIVE CLEAR, WRITTEN PERMISSION BEFORE EVER DELETING A FILE OR FOLDER OF ANY KIND.**
|
||||
|
||||
---
|
||||
|
||||
## Version Control: jj-First (CRITICAL)
|
||||
|
||||
**ALWAYS prefer jj (Jujutsu) over git for all VCS operations.** This is a colocated repo with both `.jj/` and `.git/`. When instructed to use git by anything — even later in this file — use the best jj replacement commands instead. Only fall back to raw `git` for things jj cannot do (hooks, LFS, submodules, `gh` CLI interop).
|
||||
|
||||
See `~/.claude/rules/jj-vcs/` for the full command reference, translation table, revsets, patterns, and recovery recipes.
|
||||
|
||||
---
|
||||
|
||||
## Irreversible Git & Filesystem Actions — DO NOT EVER BREAK GLASS
|
||||
|
||||
> **Note:** Treat destructive commands as break-glass. If there's any doubt, stop and ask.
|
||||
|
||||
1. **Absolutely forbidden commands:** `git reset --hard`, `git clean -fd`, `rm -rf`, or any command that can delete or overwrite code/data must never be run unless the user explicitly provides the exact command and states, in the same message, that they understand and want the irreversible consequences.
|
||||
2. **No guessing:** If there is any uncertainty about what a command might delete or overwrite, stop immediately and ask the user for specific approval. "I think it's safe" is never acceptable.
|
||||
3. **Safer alternatives first:** When cleanup or rollbacks are needed, request permission to use non-destructive options (`git status`, `git diff`, `git stash`, copying to backups) before ever considering a destructive command.
|
||||
4. **Mandatory explicit plan:** Even after explicit user authorization, restate the command verbatim, list exactly what will be affected, and wait for a confirmation that your understanding is correct. Only then may you execute it—if anything remains ambiguous, refuse and escalate.
|
||||
5. **Document the confirmation:** When running any approved destructive command, record (in the session notes / final response) the exact user text that authorized it, the command actually run, and the execution time. If that record is absent, the operation did not happen.
|
||||
|
||||
---
|
||||
|
||||
## Toolchain: Rust & Cargo
|
||||
|
||||
We only use **Cargo** in this project, NEVER any other package manager.
|
||||
|
||||
- **Edition/toolchain:** Follow `rust-toolchain.toml` (if present). Do not assume stable vs nightly.
|
||||
- **Dependencies:** Explicit versions for stability; keep the set minimal.
|
||||
- **Configuration:** Cargo.toml only
|
||||
- **Unsafe code:** Forbidden (`#![forbid(unsafe_code)]`)
|
||||
|
||||
When writing Rust code, reference RUST_CLI_TOOLS_BEST_PRACTICES.md
|
||||
|
||||
### Release Profile
|
||||
|
||||
Use the release profile defined in `Cargo.toml`. If you need to change it, justify the
|
||||
performance/size tradeoff and how it impacts determinism and cancellation behavior.
|
||||
|
||||
---
|
||||
|
||||
## Code Editing Discipline
|
||||
|
||||
### No Script-Based Changes
|
||||
|
||||
**NEVER** run a script that processes/changes code files in this repo. Brittle regex-based transformations create far more problems than they solve.
|
||||
|
||||
- **Always make code changes manually**, even when there are many instances
|
||||
- For many simple changes: use parallel subagents
|
||||
- For subtle/complex changes: do them methodically yourself
|
||||
|
||||
### No File Proliferation
|
||||
|
||||
If you want to change something or add a feature, **revise existing code files in place**.
|
||||
|
||||
**NEVER** create variations like:
|
||||
- `mainV2.rs`
|
||||
- `main_improved.rs`
|
||||
- `main_enhanced.rs`
|
||||
|
||||
New files are reserved for **genuinely new functionality** that makes zero sense to include in any existing file. The bar for creating new files is **incredibly high**.
|
||||
|
||||
---
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
We do not care about backwards compatibility—we're in early development with no users. We want to do things the **RIGHT** way with **NO TECH DEBT**.
|
||||
|
||||
- Never create "compatibility shims"
|
||||
- Never create wrapper functions for deprecated APIs
|
||||
- Just fix the code directly
|
||||
|
||||
---
|
||||
|
||||
## Compiler Checks (CRITICAL)
|
||||
|
||||
**After any substantive code changes, you MUST verify no errors were introduced:**
|
||||
|
||||
```bash
|
||||
# Check for compiler errors and warnings
|
||||
cargo check --all-targets
|
||||
|
||||
# Check for clippy lints (pedantic + nursery are enabled)
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Verify formatting
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
If you see errors, **carefully understand and resolve each issue**. Read sufficient context to fix them the RIGHT way.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit & Property Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test
|
||||
|
||||
# Run with output
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
When adding or changing primitives, add tests that assert the core invariants:
|
||||
|
||||
- no task leaks
|
||||
- no obligation leaks
|
||||
- losers are drained after races
|
||||
- region close implies quiescence
|
||||
|
||||
Prefer deterministic lab-runtime tests for concurrency-sensitive behavior.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 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 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; 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
|
||||
|
||||
1. **Pick ready work (Beads):**
|
||||
```bash
|
||||
br ready --json # Choose highest priority, no blockers
|
||||
```
|
||||
|
||||
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. **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. **Complete (Beads is authority):**
|
||||
```bash
|
||||
br close br-123 --reason "Completed"
|
||||
liquid-mail post "[br-123] Completed: <summary with commit ref>"
|
||||
```
|
||||
|
||||
### Mapping Cheat Sheet
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## bv — Graph-Aware Triage Engine
|
||||
|
||||
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 (progress logging, decisions, cross-session context), use Liquid Mail.
|
||||
|
||||
**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**
|
||||
|
||||
### The Workflow: Start With Triage
|
||||
|
||||
**`bv --robot-triage` is your single entry point.** It returns:
|
||||
- `quick_ref`: at-a-glance counts + top 3 picks
|
||||
- `recommendations`: ranked actionable items with scores, reasons, unblock info
|
||||
- `quick_wins`: low-effort high-impact items
|
||||
- `blockers_to_clear`: items that unblock the most downstream work
|
||||
- `project_health`: status/type/priority distributions, graph metrics
|
||||
- `commands`: copy-paste shell commands for next steps
|
||||
|
||||
```bash
|
||||
bv --robot-triage # THE MEGA-COMMAND: start here
|
||||
bv --robot-next # Minimal: just the single top pick + claim command
|
||||
```
|
||||
|
||||
### Command Reference
|
||||
|
||||
**Planning:**
|
||||
| Command | Returns |
|
||||
|---------|---------|
|
||||
| `--robot-plan` | Parallel execution tracks with `unblocks` lists |
|
||||
| `--robot-priority` | Priority misalignment detection with confidence |
|
||||
|
||||
**Graph Analysis:**
|
||||
| Command | Returns |
|
||||
|---------|---------|
|
||||
| `--robot-insights` | Full metrics: PageRank, betweenness, HITS, eigenvector, critical path, cycles, k-core, articulation points, slack |
|
||||
| `--robot-label-health` | Per-label health: `health_level`, `velocity_score`, `staleness`, `blocked_count` |
|
||||
| `--robot-label-flow` | Cross-label dependency: `flow_matrix`, `dependencies`, `bottleneck_labels` |
|
||||
| `--robot-label-attention [--attention-limit=N]` | Attention-ranked labels |
|
||||
|
||||
**History & Change Tracking:**
|
||||
| Command | Returns |
|
||||
|---------|---------|
|
||||
| `--robot-history` | Bead-to-commit correlations |
|
||||
| `--robot-diff --diff-since <ref>` | Changes since ref: new/closed/modified issues, cycles |
|
||||
|
||||
**Other:**
|
||||
| Command | Returns |
|
||||
|---------|---------|
|
||||
| `--robot-burndown <sprint>` | Sprint burndown, scope changes, at-risk items |
|
||||
| `--robot-forecast <id\|all>` | ETA predictions with dependency-aware scheduling |
|
||||
| `--robot-alerts` | Stale issues, blocking cascades, priority mismatches |
|
||||
| `--robot-suggest` | Hygiene: duplicates, missing deps, label suggestions |
|
||||
| `--robot-graph [--graph-format=json\|dot\|mermaid]` | Dependency graph export |
|
||||
| `--export-graph <file.html>` | Interactive HTML visualization |
|
||||
|
||||
### Scoping & Filtering
|
||||
|
||||
```bash
|
||||
bv --robot-plan --label backend # Scope to label's subgraph
|
||||
bv --robot-insights --as-of HEAD~30 # Historical point-in-time
|
||||
bv --recipe actionable --robot-plan # Pre-filter: ready to work
|
||||
bv --recipe high-impact --robot-triage # Pre-filter: top PageRank
|
||||
bv --robot-triage --robot-triage-by-track # Group by parallel work streams
|
||||
bv --robot-triage --robot-triage-by-label # Group by domain
|
||||
```
|
||||
|
||||
### Understanding Robot Output
|
||||
|
||||
**All robot JSON includes:**
|
||||
- `data_hash` — Fingerprint of source beads.jsonl
|
||||
- `status` — Per-metric state: `computed|approx|timeout|skipped` + elapsed ms
|
||||
- `as_of` / `as_of_commit` — Present when using `--as-of`
|
||||
|
||||
**Two-phase analysis:**
|
||||
- **Phase 1 (instant):** degree, topo sort, density
|
||||
- **Phase 2 (async, 500ms timeout):** PageRank, betweenness, HITS, eigenvector, cycles
|
||||
|
||||
### jq Quick Reference
|
||||
|
||||
```bash
|
||||
bv --robot-triage | jq '.quick_ref' # At-a-glance summary
|
||||
bv --robot-triage | jq '.recommendations[0]' # Top recommendation
|
||||
bv --robot-plan | jq '.plan.summary.highest_impact' # Best unblock target
|
||||
bv --robot-insights | jq '.status' # Check metric readiness
|
||||
bv --robot-insights | jq '.Cycles' # Circular deps (must fix!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UBS — Ultimate Bug Scanner
|
||||
|
||||
**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
ubs file.rs file2.rs # Specific files (< 1s) — USE THIS
|
||||
ubs $(jj diff --name-only) # Changed files — before commit
|
||||
ubs --only=rust,toml src/ # Language filter (3-5x faster)
|
||||
ubs --ci --fail-on-warning . # CI mode — before PR
|
||||
ubs . # Whole project (ignores target/, Cargo.lock)
|
||||
```
|
||||
|
||||
### Output Format
|
||||
|
||||
```
|
||||
⚠️ Category (N errors)
|
||||
file.rs:42:5 – Issue description
|
||||
💡 Suggested fix
|
||||
Exit code: 1
|
||||
```
|
||||
|
||||
Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fail
|
||||
|
||||
### Fix Workflow
|
||||
|
||||
1. Read finding → category + fix suggestion
|
||||
2. Navigate `file:line:col` → view context
|
||||
3. Verify real issue (not false positive)
|
||||
4. Fix root cause (not symptom)
|
||||
5. Re-run `ubs <file>` → exit 0
|
||||
6. Commit
|
||||
|
||||
### Bug Severity
|
||||
|
||||
- **Critical (always fix):** Memory safety, use-after-free, data races, SQL injection
|
||||
- **Important (production):** Unwrap panics, resource leaks, overflow checks
|
||||
- **Contextual (judgment):** TODO/FIXME, println! debugging
|
||||
|
||||
---
|
||||
|
||||
## ast-grep vs ripgrep
|
||||
|
||||
**Use `ast-grep` when structure matters.** It parses code and matches AST nodes, ignoring comments/strings, and can **safely rewrite** code.
|
||||
|
||||
- Refactors/codemods: rename APIs, change import forms
|
||||
- Policy checks: enforce patterns across a repo
|
||||
- Editor/automation: LSP mode, `--json` output
|
||||
|
||||
**Use `ripgrep` when text is enough.** Fastest way to grep literals/regex.
|
||||
|
||||
- Recon: find strings, TODOs, log lines, config values
|
||||
- Pre-filter: narrow candidate files before ast-grep
|
||||
|
||||
### Rule of Thumb
|
||||
|
||||
- Need correctness or **applying changes** → `ast-grep`
|
||||
- Need raw speed or **hunting text** → `rg`
|
||||
- Often combine: `rg` to shortlist files, then `ast-grep` to match/modify
|
||||
|
||||
### Rust Examples
|
||||
|
||||
```bash
|
||||
# Find structured code (ignores comments)
|
||||
ast-grep run -l Rust -p 'fn $NAME($$$ARGS) -> $RET { $$$BODY }'
|
||||
|
||||
# Find all unwrap() calls
|
||||
ast-grep run -l Rust -p '$EXPR.unwrap()'
|
||||
|
||||
# Quick textual hunt
|
||||
rg -n 'println!' -t rust
|
||||
|
||||
# Combine speed + precision
|
||||
rg -l -t rust 'unwrap\(' | xargs ast-grep run -l Rust -p '$X.unwrap()' --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Morph Warp Grep — AI-Powered Code Search
|
||||
|
||||
**Use `mcp__morph-mcp__warp_grep` for exploratory "how does X work?" questions.** An AI agent expands your query, greps the codebase, reads relevant files, and returns precise line ranges with full context.
|
||||
|
||||
**Use `ripgrep` for targeted searches.** When you know exactly what you're looking for.
|
||||
|
||||
**Use `ast-grep` for structural patterns.** When you need AST precision for matching/rewriting.
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Tool | Why |
|
||||
|----------|------|-----|
|
||||
| "How is pattern matching implemented?" | `warp_grep` | Exploratory; don't know where to start |
|
||||
| "Where is the quick reject filter?" | `warp_grep` | Need to understand architecture |
|
||||
| "Find all uses of `Regex::new`" | `ripgrep` | Targeted literal search |
|
||||
| "Find files with `println!`" | `ripgrep` | Simple pattern |
|
||||
| "Replace all `unwrap()` with `expect()`" | `ast-grep` | Structural refactor |
|
||||
|
||||
### warp_grep Usage
|
||||
|
||||
```
|
||||
mcp__morph-mcp__warp_grep(
|
||||
repoPath: "/path/to/dcg",
|
||||
query: "How does the safe pattern whitelist work?"
|
||||
)
|
||||
```
|
||||
|
||||
Returns structured results with file paths, line ranges, and extracted code snippets.
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Don't** use `warp_grep` to find a specific function name → use `ripgrep`
|
||||
- **Don't** use `ripgrep` to understand "how does X work" → wastes time with manual reads
|
||||
- **Don't** use `ripgrep` for codemods → risks collateral edits
|
||||
|
||||
<!-- bv-agent-instructions-v1 -->
|
||||
|
||||
---
|
||||
|
||||
## Beads Workflow Integration
|
||||
|
||||
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in version control.
|
||||
|
||||
**Note:** `br` is non-invasive—it never executes VCS commands directly. You must commit manually after `br sync --flush-only`.
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# View issues (launches TUI - avoid in automated sessions)
|
||||
bv
|
||||
|
||||
# CLI commands for agents (use these instead)
|
||||
br ready # Show issues ready to work (no blockers)
|
||||
br list --status=open # All open issues
|
||||
br show <id> # Full issue details with dependencies
|
||||
br create --title="..." --type=task --priority=2
|
||||
br update <id> --status=in_progress
|
||||
br close <id> --reason="Completed"
|
||||
br close <id1> <id2> # Close multiple issues at once
|
||||
br sync --flush-only # Export to JSONL (then: jj commit -m "Update beads")
|
||||
```
|
||||
|
||||
### Workflow Pattern
|
||||
|
||||
1. **Start**: Run `br ready` to find actionable work
|
||||
2. **Claim**: Use `br update <id> --status=in_progress`
|
||||
3. **Work**: Implement the task
|
||||
4. **Complete**: Use `br close <id>`
|
||||
5. **Sync**: Run `br sync --flush-only`, then `git add .beads/ && git commit -m "Update beads"`
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work.
|
||||
- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words)
|
||||
- **Types**: task, bug, feature, epic, question, docs
|
||||
- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies
|
||||
|
||||
### Session Protocol
|
||||
|
||||
**Before ending any session, run this checklist (solo/lead only — workers skip VCS):**
|
||||
|
||||
```bash
|
||||
jj status # Check what changed
|
||||
br sync --flush-only # Export beads to JSONL
|
||||
jj commit -m "..." # Commit code and beads (jj auto-tracks all changes)
|
||||
jj bookmark set <name> -r @- # Point bookmark at committed work
|
||||
jj git push -b <name> # Push to remote
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Check `br ready` at session start to find available work
|
||||
- Update status as you work (in_progress → closed)
|
||||
- Create new issues with `br create` when you discover tasks
|
||||
- Use descriptive titles and set appropriate priority/type
|
||||
- Always run `br sync --flush-only` then commit before ending session (jj auto-tracks .beads/)
|
||||
|
||||
<!-- end-bv-agent-instructions -->
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until push succeeds.
|
||||
|
||||
**WHO RUNS THIS:** Solo agents run it themselves. In multi-agent sessions, ONLY the team lead runs this. Workers skip VCS entirely.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
jj git fetch # Get latest remote state
|
||||
jj rebase -d trunk() # Rebase onto latest trunk if needed
|
||||
br sync --flush-only # Export beads to JSONL
|
||||
jj commit -m "Update beads" # Commit (jj auto-tracks .beads/ changes)
|
||||
jj bookmark set <name> -r @- # Point bookmark at committed work
|
||||
jj git push -b <name> # Push to remote
|
||||
jj log -r '<name>' # Verify bookmark position
|
||||
```
|
||||
5. **Clean up** - Abandon empty orphan changes if any (`jj abandon <rev>`)
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `jj git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
|
||||
---
|
||||
|
||||
## cass — Cross-Agent Session Search
|
||||
|
||||
`cass` indexes prior agent conversations (Claude Code, Codex, Cursor, Gemini, ChatGPT, etc.) so we can reuse solved problems.
|
||||
|
||||
**Rules:** Never run bare `cass` (TUI). Always use `--robot` or `--json`.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
cass health
|
||||
cass search "async runtime" --robot --limit 5
|
||||
cass view /path/to/session.jsonl -n 42 --json
|
||||
cass expand /path/to/session.jsonl -n 42 -C 3 --json
|
||||
cass capabilities --json
|
||||
cass robot-docs guide
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `--fields minimal` for lean output
|
||||
- Filter by agent with `--agent`
|
||||
- Use `--days N` to limit to recent history
|
||||
|
||||
stdout is data-only, stderr is diagnostics; exit code 0 means success.
|
||||
|
||||
Treat cass as a way to avoid re-solving problems other agents already handled.
|
||||
|
||||
---
|
||||
|
||||
## Note for Codex/GPT-5.2
|
||||
|
||||
You constantly bother me and stop working with concerned questions that look similar to this:
|
||||
|
||||
```
|
||||
Unexpected changes (need guidance)
|
||||
|
||||
- Working tree still shows edits I did not make in Cargo.toml, Cargo.lock, src/runtime.rs, src/scope.rs. Please advise whether to keep/commit/revert these before any further work. I did not touch them.
|
||||
|
||||
Next steps (pick one)
|
||||
|
||||
1. Decide how to handle the unrelated modified files above so we can resume cleanly.
|
||||
```
|
||||
|
||||
NEVER EVER DO THAT AGAIN. The answer is literally ALWAYS the same: those are changes created by the potentially dozen of other agents working on the project at the same time. This is not only a common occurence, it happens multiple times PER MINUTE. The way to deal with it is simple: you NEVER, under ANY CIRCUMSTANCE, stash, revert, overwrite, or otherwise disturb in ANY way the work of other agents. Just treat those changes identically to changes that you yourself made. Just fool yourself into thinking YOU made the changes and simply don't recall it for some reason.
|
||||
|
||||
---
|
||||
|
||||
## Note on Built-in TODO Functionality
|
||||
|
||||
Also, if I ask you to explicitly use your built-in TODO functionality, don't complain about this and say you need to use beads. You can use built-in TODOs if I tell you specifically to do so. Always comply with such orders.
|
||||
|
||||
## TDD Requirements
|
||||
|
||||
Test-first development is mandatory:
|
||||
1. **RED** - Write failing test first
|
||||
2. **GREEN** - Minimal implementation to pass
|
||||
3. **REFACTOR** - Clean up while green
|
||||
|
||||
## Key Patterns
|
||||
|
||||
Find the simplest solution that meets all acceptance criteria.
|
||||
Use third party libraries whenever there's a well-maintained, active, and widely adopted solution (for example, date-fns for TS date math)
|
||||
Build extensible pieces of logic that can easily be integrated with other pieces.
|
||||
DRY principles should be loosely held.
|
||||
Architecture MUST be clear and well thought-out. Ask the user for clarification whenever ambiguity is discovered around architecture, or you think a better approach than planned exists.
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Library Usage
|
||||
|
||||
If you aren't 100% sure how to use a third-party library, **SEARCH ONLINE** to find the latest documentation and mid-2025 best practices.
|
||||
|
||||
---
|
||||
|
||||
## Gitlore Robot Mode
|
||||
|
||||
The `lore` CLI has a robot mode optimized for AI agent consumption with compact JSON output, structured errors with machine-actionable recovery steps, meaningful exit codes, response timing metadata, field selection for token efficiency, and TTY auto-detection.
|
||||
|
||||
### Activation
|
||||
|
||||
```bash
|
||||
# Explicit flag
|
||||
lore --robot issues -n 10
|
||||
|
||||
# JSON shorthand (-J)
|
||||
lore -J issues -n 10
|
||||
|
||||
# Auto-detection (when stdout is not a TTY)
|
||||
lore issues | jq .
|
||||
|
||||
# Environment variable
|
||||
LORE_ROBOT=1 lore issues
|
||||
```
|
||||
|
||||
### Robot Mode Commands
|
||||
|
||||
```bash
|
||||
# List issues/MRs with JSON output
|
||||
lore --robot issues -n 10
|
||||
lore --robot mrs -s opened
|
||||
|
||||
# Filter issues by work item status (case-insensitive)
|
||||
lore --robot issues --status "In progress"
|
||||
|
||||
# List with field selection (reduces token usage ~60%)
|
||||
lore --robot issues --fields minimal
|
||||
lore --robot mrs --fields iid,title,state,draft
|
||||
|
||||
# Show detailed entity info
|
||||
lore --robot issues 123
|
||||
lore --robot mrs 456 -p group/repo
|
||||
|
||||
# Count entities
|
||||
lore --robot count issues
|
||||
lore --robot count discussions --for mr
|
||||
|
||||
# Search indexed documents
|
||||
lore --robot search "authentication bug"
|
||||
|
||||
# Check sync status
|
||||
lore --robot status
|
||||
|
||||
# Run full sync pipeline
|
||||
lore --robot sync
|
||||
|
||||
# Run sync without resource events
|
||||
lore --robot sync --no-events
|
||||
|
||||
# Surgical sync: specific entities by IID
|
||||
lore --robot sync --issue 42 -p group/repo
|
||||
lore --robot sync --mr 99 --mr 100 -p group/repo
|
||||
|
||||
# Run ingestion only
|
||||
lore --robot ingest issues
|
||||
|
||||
# Trace why code was introduced
|
||||
lore --robot trace src/main.rs -p group/repo
|
||||
|
||||
# File-level MR history
|
||||
lore --robot file-history src/auth/ -p group/repo
|
||||
|
||||
# Manage cron-based auto-sync (Unix)
|
||||
lore --robot cron status
|
||||
lore --robot cron install --interval 15
|
||||
|
||||
# Token management
|
||||
lore --robot token show
|
||||
|
||||
# Check environment health
|
||||
lore --robot doctor
|
||||
|
||||
# Document and index statistics
|
||||
lore --robot stats
|
||||
|
||||
# Quick health pre-flight check (exit 0 = healthy, 19 = unhealthy)
|
||||
lore --robot health
|
||||
|
||||
# Generate searchable documents from ingested data
|
||||
lore --robot generate-docs
|
||||
|
||||
# Generate vector embeddings via Ollama
|
||||
lore --robot embed
|
||||
|
||||
# Personal work dashboard
|
||||
lore --robot me
|
||||
lore --robot me --issues
|
||||
lore --robot me --mrs
|
||||
lore --robot me --activity --since 7d
|
||||
lore --robot me --project group/repo
|
||||
lore --robot me --user jdoe
|
||||
lore --robot me --fields minimal
|
||||
lore --robot me --reset-cursor
|
||||
|
||||
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||
lore robot-docs
|
||||
|
||||
# Version information
|
||||
lore --robot version
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
All commands return compact JSON with a uniform envelope and timing metadata:
|
||||
|
||||
```json
|
||||
{"ok":true,"data":{...},"meta":{"elapsed_ms":42}}
|
||||
```
|
||||
|
||||
Errors return structured JSON to stderr with machine-actionable recovery steps:
|
||||
|
||||
```json
|
||||
{"error":{"code":"CONFIG_NOT_FOUND","message":"...","suggestion":"Run 'lore init'","actions":["lore init"]}}
|
||||
```
|
||||
|
||||
The `actions` array contains executable shell commands for automated recovery. It is omitted when empty.
|
||||
|
||||
### Field Selection
|
||||
|
||||
The `--fields` flag on `issues` and `mrs` list commands controls which fields appear in the JSON response:
|
||||
|
||||
```bash
|
||||
lore -J issues --fields minimal # Preset: iid, title, state, updated_at_iso
|
||||
lore -J mrs --fields iid,title,state,draft,labels # Custom field list
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Internal error / not implemented |
|
||||
| 2 | Usage error (invalid flags or arguments) |
|
||||
| 3 | Config invalid |
|
||||
| 4 | Token not set |
|
||||
| 5 | GitLab auth failed |
|
||||
| 6 | Resource not found |
|
||||
| 7 | Rate limited |
|
||||
| 8 | Network error |
|
||||
| 9 | Database locked |
|
||||
| 10 | Database error |
|
||||
| 11 | Migration failed |
|
||||
| 12 | I/O error |
|
||||
| 13 | Transform error |
|
||||
| 14 | Ollama unavailable |
|
||||
| 15 | Ollama model not found |
|
||||
| 16 | Embedding failed |
|
||||
| 17 | Not found (entity does not exist) |
|
||||
| 18 | Ambiguous match (use `-p` to specify project) |
|
||||
| 19 | Health check failed |
|
||||
| 20 | Config not found |
|
||||
|
||||
### Configuration Precedence
|
||||
|
||||
1. CLI flags (highest priority)
|
||||
2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`)
|
||||
3. Config file (`~/.config/lore/config.json`)
|
||||
4. Built-in defaults (lowest priority)
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use `lore --robot` or `lore -J` for all agent interactions
|
||||
- Check exit codes for error handling
|
||||
- Parse JSON errors from stderr; use `actions` array for automated recovery
|
||||
- Use `--fields minimal` to reduce token usage (~60% fewer tokens)
|
||||
- Use `-n` / `--limit` to control response size
|
||||
- Use `-q` / `--quiet` to suppress progress bars and non-essential output
|
||||
- Use `--color never` in non-TTY automation for ANSI-free output
|
||||
- Use `-v` / `-vv` / `-vvv` for increasing verbosity (debug/trace logging)
|
||||
- Use `--log-format json` for machine-readable log output to stderr
|
||||
- TTY detection handles piped commands automatically
|
||||
- Use `lore --robot health` as a fast pre-flight check before queries
|
||||
- Use `lore robot-docs` for response schema discovery
|
||||
- The `-p` flag supports fuzzy project matching (suffix and substring)
|
||||
|
||||
---
|
||||
|
||||
## Read/Write Split: lore vs glab
|
||||
|
||||
| Operation | Tool | Why |
|
||||
|-----------|------|-----|
|
||||
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
|
||||
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
|
||||
| Search across entities | lore | FTS5 + vector hybrid search |
|
||||
| Expert/workload analysis | lore | who command — no glab equivalent |
|
||||
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
|
||||
| Create/update/close | glab | Write operations |
|
||||
| Approve/merge MR | glab | Write operations |
|
||||
| CI/CD pipelines | glab | Not in lore scope |
|
||||
|
||||
````markdown
|
||||
## UBS Quick Reference for AI Agents
|
||||
|
||||
UBS stands for "Ultimate Bug Scanner": **The AI Coding Agent's Secret Weapon: Flagging Likely Bugs for Fixing Early On**
|
||||
|
||||
**Install:** `curl -sSL https://raw.githubusercontent.com/Dicklesworthstone/ultimate_bug_scanner/master/install.sh | bash`
|
||||
|
||||
**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run.
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
ubs file.ts file2.py # Specific files (< 1s) — USE THIS
|
||||
ubs $(git diff --name-only --cached) # Staged files — before commit
|
||||
ubs --only=js,python src/ # Language filter (3-5x faster)
|
||||
ubs --ci --fail-on-warning . # CI mode — before PR
|
||||
ubs --help # Full command reference
|
||||
ubs sessions --entries 1 # Tail the latest install session log
|
||||
ubs . # Whole project (ignores things like .venv and node_modules automatically)
|
||||
```
|
||||
|
||||
**Output Format:**
|
||||
```
|
||||
⚠️ Category (N errors)
|
||||
file.ts:42:5 – Issue description
|
||||
💡 Suggested fix
|
||||
Exit code: 1
|
||||
```
|
||||
Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fail
|
||||
|
||||
**Fix Workflow:**
|
||||
1. Read finding → category + fix suggestion
|
||||
2. Navigate `file:line:col` → view context
|
||||
3. Verify real issue (not false positive)
|
||||
4. Fix root cause (not symptom)
|
||||
5. Re-run `ubs <file>` → exit 0
|
||||
6. Commit
|
||||
|
||||
**Speed Critical:** Scope to changed files. `ubs src/file.ts` (< 1s) vs `ubs .` (30s). Never full scan for small edits.
|
||||
|
||||
**Bug Severity:**
|
||||
- **Critical** (always fix): Null safety, XSS/injection, async/await, memory leaks
|
||||
- **Important** (production): Type narrowing, division-by-zero, resource leaks
|
||||
- **Contextual** (judgment): TODO/FIXME, console logs
|
||||
|
||||
**Anti-Patterns:**
|
||||
- ❌ Ignore findings → ✅ Investigate each
|
||||
- ❌ 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 -->
|
||||
174
Cargo.lock
generated
174
Cargo.lock
generated
@@ -169,6 +169,24 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "charmed-lipgloss"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5986a4a6d84055da99e44a6c532fd412d636fe5c3fe17da105a7bf40287ccd1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"colored",
|
||||
"crossterm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -239,14 +257,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "comfy-table"
|
||||
version = "7.2.2"
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"lazy_static",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -258,10 +275,19 @@ dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -319,9 +345,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -371,6 +401,28 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dialoguer"
|
||||
version = "0.12.0"
|
||||
@@ -976,7 +1028,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
@@ -1106,13 +1158,13 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lore"
|
||||
version = "0.6.2"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"charmed-lipgloss",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"comfy-table",
|
||||
"console",
|
||||
"dialoguer",
|
||||
"dirs",
|
||||
@@ -1181,6 +1233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -1574,6 +1627,15 @@ dependencies = [
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
@@ -1670,6 +1732,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1713,6 +1781,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -1757,6 +1834,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.5"
|
||||
@@ -2028,6 +2126,47 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -2183,6 +2322,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
@@ -2611,6 +2756,15 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.5"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lore"
|
||||
version = "0.6.2"
|
||||
version = "0.9.1"
|
||||
edition = "2024"
|
||||
description = "Gitlore - Local GitLab data management with semantic search"
|
||||
authors = ["Taylor Eernisse"]
|
||||
@@ -25,7 +25,7 @@ clap_complete = "4"
|
||||
dialoguer = "0.12"
|
||||
console = "0.16"
|
||||
indicatif = "0.18"
|
||||
comfy-table = "7"
|
||||
lipgloss = { package = "charmed-lipgloss", version = "0.2", default-features = false, features = ["native"] }
|
||||
open = "5"
|
||||
|
||||
# HTTP
|
||||
|
||||
425
PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md
Normal file
425
PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Proposed Code File Reorganization Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase is 79 Rust source files / 46K lines across 7 top-level modules. Most modules (`gitlab/`, `embedding/`, `search/`, `documents/`, `ingestion/`) are well-organized. The pain points are:
|
||||
|
||||
1. **`core/` is a grab-bag** — 22 files mixing infrastructure, domain logic, DB operations, and an entire timeline pipeline
|
||||
2. **`main.rs` is 2713 lines** — ~30 handler functions that bridge CLI args to commands
|
||||
3. **`cli/mod.rs` is 949 lines** — every clap argument struct is packed into one file
|
||||
4. **Giant command files** — `who.rs` (6067 lines), `list.rs` (2931 lines) are unwieldy
|
||||
|
||||
This plan is organized into **three tiers** based on impact-to-risk ratio. Tier 1 changes are "no-brainers" — they reduce confusion with minimal import churn. Tier 2 changes are valuable but involve more cross-cutting import updates. Tier 3 changes are "maybe later" — they'd be nice but the juice might not be worth the squeeze right now.
|
||||
|
||||
---
|
||||
|
||||
## Current Structure (Annotated)
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs (2713 lines) ← dispatch + ~30 handler functions + error helpers
|
||||
├── lib.rs (9 lines)
|
||||
├── cli/
|
||||
│ ├── mod.rs (949 lines) ← ALL clap arg structs crammed here
|
||||
│ ├── autocorrect.rs (945 lines)
|
||||
│ ├── progress.rs (92 lines)
|
||||
│ ├── robot.rs (111 lines)
|
||||
│ └── commands/
|
||||
│ ├── mod.rs (50 lines) — re-exports
|
||||
│ ├── auth_test.rs
|
||||
│ ├── count.rs (406 lines)
|
||||
│ ├── doctor.rs (576 lines)
|
||||
│ ├── drift.rs (642 lines)
|
||||
│ ├── embed.rs
|
||||
│ ├── generate_docs.rs (320 lines)
|
||||
│ ├── ingest.rs (1064 lines)
|
||||
│ ├── init.rs (174 lines)
|
||||
│ ├── list.rs (2931 lines) ← handles issues, MRs, AND notes listing
|
||||
│ ├── search.rs (418 lines)
|
||||
│ ├── show.rs (1377 lines)
|
||||
│ ├── stats.rs (505 lines)
|
||||
│ ├── sync_status.rs (454 lines)
|
||||
│ ├── sync.rs (576 lines)
|
||||
│ ├── timeline.rs (488 lines)
|
||||
│ └── who.rs (6067 lines) ← 5 sub-modes: expert, workload, active, overlap, reviews
|
||||
├── core/
|
||||
│ ├── mod.rs (25 lines)
|
||||
│ ├── backoff.rs ← retry logic (used by ingestion)
|
||||
│ ├── config.rs (789 lines) ← configuration types
|
||||
│ ├── db.rs (970 lines) ← connection + 22 migrations
|
||||
│ ├── dependent_queue.rs (330 lines) ← job queue (used by ingestion orchestrator)
|
||||
│ ├── error.rs (295 lines) ← error enum + exit codes
|
||||
│ ├── events_db.rs (199 lines) ← resource event upserts (used by ingestion)
|
||||
│ ├── lock.rs (228 lines) ← filesystem sync lock
|
||||
│ ├── logging.rs (179 lines) ← tracing filter builders
|
||||
│ ├── metrics.rs (566 lines) ← tracing-based stage timing
|
||||
│ ├── note_parser.rs (563 lines) ← cross-ref extraction from note bodies
|
||||
│ ├── paths.rs ← config/db/log file path resolution
|
||||
│ ├── payloads.rs (204 lines) ← raw JSON payload storage
|
||||
│ ├── project.rs (274 lines) ← fuzzy project resolution from DB
|
||||
│ ├── references.rs (551 lines) ← entity cross-reference extraction
|
||||
│ ├── shutdown.rs ← graceful shutdown via tokio signal
|
||||
│ ├── sync_run.rs (218 lines) ← sync run recording to DB
|
||||
│ ├── time.rs ← time conversion utilities
|
||||
│ ├── timeline.rs (284 lines) ← timeline types + EntityRef
|
||||
│ ├── timeline_collect.rs (695 lines) ← Stage 4: collect events from DB
|
||||
│ ├── timeline_expand.rs (557 lines) ← Stage 3: expand via cross-refs
|
||||
│ └── timeline_seed.rs (552 lines) ← Stage 1: FTS search seeding
|
||||
├── documents/ ← well-organized, 3 focused files
|
||||
├── embedding/ ← well-organized, 6 focused files
|
||||
├── gitlab/ ← well-organized, with transformers/ subdir
|
||||
├── ingestion/ ← well-organized, 8 focused files
|
||||
└── search/ ← well-organized, 5 focused files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: No-Brainers (Do First)
|
||||
|
||||
### 1.1 Extract `timeline/` from `core/`
|
||||
|
||||
**What:** Move the 4 timeline files into their own top-level module `src/timeline/`.
|
||||
|
||||
**Current location:**
|
||||
- `core/timeline.rs` (284 lines) — types: `EntityRef`, `ExpandedEntityRef`, `TimelineEvent`, `TimelineEventType`, etc.
|
||||
- `core/timeline_seed.rs` (552 lines) — Stage 1: FTS-based seeding
|
||||
- `core/timeline_expand.rs` (557 lines) — Stage 3: cross-reference expansion
|
||||
- `core/timeline_collect.rs` (695 lines) — Stage 4: event collection from DB
|
||||
|
||||
**New structure:**
|
||||
```
|
||||
src/timeline/
|
||||
├── mod.rs ← types (from timeline.rs) + re-exports
|
||||
├── seed.rs ← from timeline_seed.rs
|
||||
├── expand.rs ← from timeline_expand.rs
|
||||
└── collect.rs ← from timeline_collect.rs
|
||||
```
|
||||
|
||||
**Rationale:** These 4 files form a cohesive 5-stage pipeline (SEED→HYDRATE→EXPAND→COLLECT→RENDER). They have nothing to do with "core" infrastructure like `db.rs`, `config.rs`, or `error.rs`. They only import from `core::error`, `core::time`, and `search::fts` — all of which remain accessible via `crate::core::*` and `crate::search::*` after the move.
|
||||
|
||||
**Import changes needed:**
|
||||
- `cli/commands/timeline.rs`: `use crate::core::timeline::*` → `use crate::timeline::*`, same for `timeline_seed`, `timeline_expand`, `timeline_collect`
|
||||
- `core/mod.rs`: remove the 4 `pub mod timeline*` lines
|
||||
- `lib.rs`: add `pub mod timeline;`
|
||||
|
||||
**Risk: LOW** — Only 1 consumer (`cli/commands/timeline.rs`) + internal cross-references between the 4 files.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Extract `xref/` (cross-reference extraction) from `core/`
|
||||
|
||||
**What:** Move `note_parser.rs` and `references.rs` into `src/xref/`.
|
||||
|
||||
**Current location:**
|
||||
- `core/note_parser.rs` (563 lines) — parses note bodies for "mentioned in group/repo#123" patterns, persists to `note_cross_references` table
|
||||
- `core/references.rs` (551 lines) — extracts entity references from state events and closing MRs, writes to `entity_references` table
|
||||
|
||||
**New structure:**
|
||||
```
|
||||
src/xref/
|
||||
├── mod.rs ← re-exports
|
||||
├── note_parser.rs ← from core/note_parser.rs
|
||||
└── references.rs ← from core/references.rs
|
||||
```
|
||||
|
||||
**Rationale:** These files implement a specific domain concept — extracting and persisting cross-references between issues and MRs. They are not "core infrastructure." They're consumed by `ingestion/orchestrator.rs` for the cross-reference extraction phase, and the data they produce is consumed by the timeline pipeline. Putting them in their own module makes the data flow clearer: `ingestion → xref → timeline`.
|
||||
|
||||
**Import changes needed:**
|
||||
- `ingestion/orchestrator.rs`: `use crate::core::references::*` → `use crate::xref::references::*`
|
||||
- `ingestion/orchestrator.rs`: `use crate::core::note_parser::*` (if used directly — needs verification) → `use crate::xref::*`
|
||||
- `core/mod.rs`: remove `pub mod note_parser; pub mod references;`
|
||||
- `lib.rs`: add `pub mod xref;`
|
||||
- Internal: the files use `super::error::Result` and `super::time::now_ms` which become `crate::core::error::Result` and `crate::core::time::now_ms`
|
||||
|
||||
**Risk: LOW** — 2-3 consumers at most. The files already use `super::` internally which just needs updating to `crate::core::`.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Good Improvements (Do After Tier 1)
|
||||
|
||||
### 2.1 Group ingestion-adjacent DB operations
|
||||
|
||||
**What:** Move `events_db.rs`, `dependent_queue.rs`, `payloads.rs`, and `sync_run.rs` from `core/` into `ingestion/` since they exclusively serve the ingestion pipeline.
|
||||
|
||||
**Current consumers:**
|
||||
- `events_db.rs` → only used by `cli/commands/count.rs` (for event counts)
|
||||
- `dependent_queue.rs` → only used by `ingestion/orchestrator.rs` and `main.rs` (to release locked jobs)
|
||||
- `payloads.rs` → only used by `ingestion/discussions.rs`, `ingestion/issues.rs`, `ingestion/merge_requests.rs`, `ingestion/mr_discussions.rs`
|
||||
- `sync_run.rs` → only used by `cli/commands/sync.rs` and `cli/commands/sync_status.rs`
|
||||
|
||||
**New structure:**
|
||||
```
|
||||
src/ingestion/
|
||||
├── (existing files...)
|
||||
├── events_db.rs ← from core/events_db.rs
|
||||
├── dependent_queue.rs ← from core/dependent_queue.rs
|
||||
├── payloads.rs ← from core/payloads.rs
|
||||
└── sync_run.rs ← from core/sync_run.rs
|
||||
```
|
||||
|
||||
**Rationale:** All 4 files exist to support the ingestion pipeline:
|
||||
- `events_db.rs` upserts resource state/label/milestone events fetched during ingestion
|
||||
- `dependent_queue.rs` manages the job queue that drives incremental discussion fetching
|
||||
- `payloads.rs` stores the raw JSON payloads fetched from GitLab
|
||||
- `sync_run.rs` records when syncs start/finish and their metrics
|
||||
|
||||
When you're looking for "how does ingestion work?", you'd naturally look in `ingestion/`. Having these scattered in `core/` requires knowing the hidden dependency.
|
||||
|
||||
**Import changes needed:**
|
||||
- `events_db.rs`: 1 consumer in `cli/commands/count.rs` changes from `crate::core::events_db` → `crate::ingestion::events_db`
|
||||
- `dependent_queue.rs`: 2 consumers — `ingestion/orchestrator.rs` (becomes `super::dependent_queue`) and `main.rs`
|
||||
- `payloads.rs`: 4 consumers in `ingestion/*.rs` (become `super::payloads`)
|
||||
- `sync_run.rs`: 2 consumers in `cli/commands/sync.rs` and `sync_status.rs`
|
||||
- Internal references change from `super::error` / `super::time` to `crate::core::error` / `crate::core::time`
|
||||
|
||||
**Risk: MEDIUM** — More import changes, but all straightforward. The internal `super::` references need the most attention.
|
||||
|
||||
**Alternatively:** If moving feels like too much churn, a lighter option is to create `core/ingestion_db.rs` that re-exports from these 4 files, making the grouping visible without moving files. But I think the move is cleaner.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Split `cli/mod.rs` — move arg structs to their command files
|
||||
|
||||
**What:** Move each `*Args` struct from `cli/mod.rs` into the corresponding `cli/commands/*.rs` file. Keep `Cli` struct, `Commands` enum, and `detect_robot_mode_from_env()` in `cli/mod.rs`.
|
||||
|
||||
**Currently `cli/mod.rs` (949 lines) contains:**
|
||||
- `Cli` struct (81 lines) — the root clap parser
|
||||
- `Commands` enum (193 lines) — all subcommand variants
|
||||
- `IssuesArgs` (86 lines) → move to `commands/list.rs` or stay near issues handling
|
||||
- `MrsArgs` (93 lines) → move to `commands/list.rs` or stay near MRs handling
|
||||
- `NotesArgs` (99 lines) → move to `commands/list.rs`
|
||||
- `IngestArgs` (33 lines) → move to `commands/ingest.rs`
|
||||
- `StatsArgs` (19 lines) → move to `commands/stats.rs`
|
||||
- `SearchArgs` (58 lines) → move to `commands/search.rs`
|
||||
- `GenerateDocsArgs` (9 lines) → move to `commands/generate_docs.rs`
|
||||
- `SyncArgs` (39 lines) → move to `commands/sync.rs`
|
||||
- `EmbedArgs` (15 lines) → move to `commands/embed.rs`
|
||||
- `TimelineArgs` (53 lines) → move to `commands/timeline.rs`
|
||||
- `WhoArgs` (76 lines) → move to `commands/who.rs`
|
||||
- `CountArgs` (9 lines) → move to `commands/count.rs`
|
||||
|
||||
**After refactoring, `cli/mod.rs` shrinks to ~300 lines** (just `Cli` + `Commands` + the inlined variants like `Init`, `Drift`, `Backup`, `Reset`).
|
||||
|
||||
**Rationale:** When adding a new flag to the `who` command, you currently have to edit `cli/mod.rs` (the args struct), `cli/commands/who.rs` (the implementation), and `main.rs` (the dispatch). If the args struct lives in `commands/who.rs`, you only need two files. This is the standard pattern in mature clap-based Rust CLIs.
|
||||
|
||||
**Import changes needed:**
|
||||
- `main.rs` currently does `use lore::cli::{..., WhoArgs, ...}` — these would become `use lore::cli::commands::{..., WhoArgs, ...}` or the `commands/mod.rs` re-exports them
|
||||
- Each `commands/*.rs` gets its own `#[derive(Parser)]` struct
|
||||
- `Commands` enum in `cli/mod.rs` keeps using the types but imports from `commands::*`
|
||||
|
||||
**Risk: MEDIUM** — Lots of `use` path changes in `main.rs`, but purely mechanical. No logic changes.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Consider Later
|
||||
|
||||
### 3.1 Split `main.rs` (2713 lines)
|
||||
|
||||
**The problem:** `main.rs` contains `main()`, ~30 `handle_*` functions, error handling, clap error formatting, fuzzy command matching, and the `robot-docs` JSON manifest (a 400+ line inline JSON literal).
|
||||
|
||||
**Possible approach:**
|
||||
- Extract `handle_*` functions into `cli/dispatch.rs` (the routing layer)
|
||||
- Extract error handling into `cli/errors.rs`
|
||||
- Extract `handle_robot_docs` + the JSON manifest into `cli/robot_docs.rs`
|
||||
- Keep `main()` in `main.rs` at ~150 lines (just the tracing setup + dispatch call)
|
||||
|
||||
**Why Tier 3:** This is the messiest split. The handler functions depend on the `cli::commands::*` functions AND the `cli::robot::*` helpers AND direct `std::process::exit` calls. Making this work cleanly requires careful thought about the error boundary between `main.rs` (binary) and `lib.rs` (library).
|
||||
|
||||
**Risk: HIGH** — Every handler function touches `robot_mode`, constructs its own timer, opens the DB, and manages error display. The boilerplate is high but consistent, so splitting would just move it around without reducing complexity.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Split `cli/commands/who.rs` (6067 lines)
|
||||
|
||||
**The problem:** This file implements 5 distinct modes (expert, workload, active, overlap, reviews), each with its own query, scoring model, and output formatting. It also includes the time-decay scoring model (~500 lines) and per-MR detail breakdown logic.
|
||||
|
||||
**Possible split:**
|
||||
```
|
||||
src/cli/commands/who/
|
||||
├── mod.rs ← WhoRun dispatcher, shared types
|
||||
├── expert.rs ← expert mode (path-based file expertise lookup)
|
||||
├── workload.rs ← workload mode (user's assigned issues/MRs)
|
||||
├── active.rs ← active discussions mode
|
||||
├── overlap.rs ← file overlap between users
|
||||
├── reviews.rs ← review pattern analysis
|
||||
└── scoring.rs ← time-decay expert scoring model
|
||||
```
|
||||
|
||||
**Why Tier 3:** The 5 modes share many helper functions, database connection patterns, and output formatting logic. Splitting would require carefully identifying the shared helpers and deciding where they live. The file is big but internally consistent — the modes use a shared dispatcher pattern and common types.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Split `cli/commands/list.rs` (2931 lines)
|
||||
|
||||
**The problem:** This file handles issue listing, MR listing, AND note listing — three related but distinct operations with separate query builders, output formatters, and test suites.
|
||||
|
||||
**Possible split:**
|
||||
```
|
||||
src/cli/commands/
|
||||
├── list_issues.rs ← issue listing + query builder
|
||||
├── list_mrs.rs ← MR listing + query builder
|
||||
├── list_notes.rs ← note listing + query builder
|
||||
└── list.rs ← shared types (ListFilters, etc.) + re-exports
|
||||
```
|
||||
|
||||
**Why Tier 3:** Same issue as `who.rs` — the three listing modes share query building patterns, field selection logic, and sorting code. Splitting requires identifying and extracting the shared pieces first.
|
||||
|
||||
---
|
||||
|
||||
## Files NOT Recommended to Move
|
||||
|
||||
These files belong exactly where they are:
|
||||
|
||||
| File | Why it belongs in `core/` |
|
||||
|------|--------------------------|
|
||||
| `config.rs` | Config types used by nearly everything |
|
||||
| `db.rs` | Database connection + migrations — foundational |
|
||||
| `error.rs` | Error types used by every module |
|
||||
| `paths.rs` | File path resolution — infrastructure |
|
||||
| `logging.rs` | Tracing setup — infrastructure |
|
||||
| `lock.rs` | Filesystem sync lock — infrastructure |
|
||||
| `shutdown.rs` | Graceful shutdown signal — infrastructure |
|
||||
| `backoff.rs` | Retry math — infrastructure |
|
||||
| `time.rs` | Time conversion — used everywhere |
|
||||
| `metrics.rs` | Tracing metrics layer — infrastructure |
|
||||
| `project.rs` | Fuzzy project resolution — used by 8+ consumers across modules |
|
||||
|
||||
These files are legitimate "core infrastructure" used across multiple modules. Moving them would create import churn with no clarity gain.
|
||||
|
||||
---
|
||||
|
||||
## Files NOT Recommended to Split/Merge
|
||||
|
||||
| File | Why leave it alone |
|
||||
|------|-------------------|
|
||||
| `documents/extractor.rs` (2341 lines) | One cohesive extractor per entity type — the size comes from per-type formatting logic, not mixed concerns |
|
||||
| `ingestion/orchestrator.rs` (1703 lines) | Single orchestration flow — splitting would scatter the pipeline |
|
||||
| `gitlab/graphql.rs` (1293 lines) | GraphQL client with adaptive paging — cohesive |
|
||||
| `gitlab/client.rs` (851 lines) | REST client with all endpoints — cohesive |
|
||||
| `cli/autocorrect.rs` (945 lines) | Correction registry + fuzzy matching — splitting gains nothing |
|
||||
|
||||
---
|
||||
|
||||
## Proposed Final Structure (Tiers 1+2)
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs (2713 lines — unchanged for now)
|
||||
├── lib.rs (adds: pub mod timeline; pub mod xref;)
|
||||
├── cli/
|
||||
│ ├── mod.rs (~300 lines — Cli + Commands only, args moved out)
|
||||
│ ├── autocorrect.rs (unchanged)
|
||||
│ ├── progress.rs (unchanged)
|
||||
│ ├── robot.rs (unchanged)
|
||||
│ └── commands/
|
||||
│ ├── mod.rs (re-exports + WhoArgs, IssuesArgs, etc.)
|
||||
│ ├── (all existing files — unchanged but with args structs moved in)
|
||||
│ └── ...
|
||||
├── core/ (slimmed: 14 files → infrastructure only)
|
||||
│ ├── mod.rs
|
||||
│ ├── backoff.rs
|
||||
│ ├── config.rs
|
||||
│ ├── db.rs
|
||||
│ ├── error.rs
|
||||
│ ├── lock.rs
|
||||
│ ├── logging.rs
|
||||
│ ├── metrics.rs
|
||||
│ ├── paths.rs
|
||||
│ ├── project.rs
|
||||
│ ├── shutdown.rs
|
||||
│ └── time.rs
|
||||
├── timeline/ (NEW — extracted from core/)
|
||||
│ ├── mod.rs (types from core/timeline.rs)
|
||||
│ ├── seed.rs (from core/timeline_seed.rs)
|
||||
│ ├── expand.rs (from core/timeline_expand.rs)
|
||||
│ └── collect.rs (from core/timeline_collect.rs)
|
||||
├── xref/ (NEW — extracted from core/)
|
||||
│ ├── mod.rs
|
||||
│ ├── note_parser.rs (from core/note_parser.rs)
|
||||
│ └── references.rs (from core/references.rs)
|
||||
├── ingestion/ (gains 4 files from core/)
|
||||
│ ├── (existing files...)
|
||||
│ ├── events_db.rs (from core/events_db.rs)
|
||||
│ ├── dependent_queue.rs (from core/dependent_queue.rs)
|
||||
│ ├── payloads.rs (from core/payloads.rs)
|
||||
│ └── sync_run.rs (from core/sync_run.rs)
|
||||
├── documents/ (unchanged)
|
||||
├── embedding/ (unchanged)
|
||||
├── gitlab/ (unchanged)
|
||||
└── search/ (unchanged)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Change Tracking
|
||||
|
||||
### Tier 1.1: Timeline extraction
|
||||
|
||||
| Consumer file | Old import | New import |
|
||||
|---------------|-----------|------------|
|
||||
| `cli/commands/timeline.rs:10-15` | `crate::core::timeline::*` | `crate::timeline::*` |
|
||||
| `cli/commands/timeline.rs:13` | `crate::core::timeline_collect::collect_events` | `crate::timeline::collect_events` (or `crate::timeline::collect::collect_events`) |
|
||||
| `cli/commands/timeline.rs:14` | `crate::core::timeline_expand::expand_timeline` | `crate::timeline::expand_timeline` |
|
||||
| `cli/commands/timeline.rs:15` | `crate::core::timeline_seed::seed_timeline` | `crate::timeline::seed_timeline` |
|
||||
| `core/timeline_seed.rs:7-8` | `super::timeline::*` | `super::*` (or `crate::timeline::*` depending on structure) |
|
||||
| `core/timeline_expand.rs:6` | `super::timeline::*` | `super::*` |
|
||||
| `core/timeline_collect.rs:4` | `super::timeline::*` | `super::*` |
|
||||
| `core/timeline_seed.rs:8` | `crate::search::*` | `crate::search::*` (no change) |
|
||||
| `core/timeline_seed.rs:6-7` | `super::error::Result` | `crate::core::error::Result` |
|
||||
| `core/timeline_expand.rs:5` | `super::error::Result` | `crate::core::error::Result` |
|
||||
| `core/timeline_collect.rs:3` | `super::error::*` | `crate::core::error::*` |
|
||||
|
||||
### Tier 1.2: Cross-reference extraction
|
||||
|
||||
| Consumer file | Old import | New import |
|
||||
|---------------|-----------|------------|
|
||||
| `ingestion/orchestrator.rs:10-12` | `crate::core::references::*` | `crate::xref::references::*` |
|
||||
| `core/note_parser.rs:7-8` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||
| `core/references.rs:4-5` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||
|
||||
### Tier 2.1: Ingestion-adjacent DB ops
|
||||
|
||||
| Consumer file | Old import | New import |
|
||||
|---------------|-----------|------------|
|
||||
| `cli/commands/count.rs:9` | `crate::core::events_db::*` | `crate::ingestion::events_db::*` |
|
||||
| `ingestion/orchestrator.rs:6-8` | `crate::core::dependent_queue::*` | `super::dependent_queue::*` |
|
||||
| `main.rs:37` | `crate::core::dependent_queue::release_all_locked_jobs` | `crate::ingestion::dependent_queue::release_all_locked_jobs` |
|
||||
| `ingestion/discussions.rs:7` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||
| `ingestion/issues.rs:9` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||
| `ingestion/merge_requests.rs:8` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||
| `ingestion/mr_discussions.rs:7` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||
| `cli/commands/sync.rs` | (uses `crate::core::sync_run::*`) | `crate::ingestion::sync_run::*` |
|
||||
| `cli/commands/sync_status.rs` | (uses `crate::core::sync_run::*` or `crate::core::metrics::*`) | check and update |
|
||||
| Internal: `events_db.rs:4-5` | `super::error::*`, `super::time::*` | `crate::core::error::*`, `crate::core::time::*` |
|
||||
| Internal: `dependent_queue.rs:5-6` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||
| Internal: `payloads.rs:9-10` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||
| Internal: `sync_run.rs:2-4` | `super::error::*`, `super::metrics::*`, `super::time::*` | `crate::core::error::*`, `crate::core::metrics::*`, `crate::core::time::*` |
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Tier 1.1** — Extract timeline → `src/timeline/` (LOW risk, 1 consumer)
|
||||
2. **Tier 1.2** — Extract xref → `src/xref/` (LOW risk, 1-2 consumers)
|
||||
3. **Cargo check + clippy + test** after each tier
|
||||
4. **Tier 2.1** — Move ingestion DB ops (MEDIUM risk, more consumers)
|
||||
5. **Cargo check + clippy + test**
|
||||
6. **Tier 2.2** — Split `cli/mod.rs` args (MEDIUM risk, mostly mechanical)
|
||||
7. **Cargo check + clippy + test + fmt**
|
||||
|
||||
Each tier should be its own commit for easy rollback.
|
||||
|
||||
---
|
||||
|
||||
## What This Achieves
|
||||
|
||||
**Before:** A developer looking at `core/` sees 22 files and has to mentally sort "infrastructure vs. domain logic vs. pipeline stage." The timeline pipeline is invisible unless you know to look in `core/`.
|
||||
|
||||
**After:**
|
||||
- `core/` has 12 files, all clearly infrastructure (db, config, error, paths, logging, lock, shutdown, backoff, time, metrics, project)
|
||||
- `timeline/` is a discoverable first-class module showing the 5-stage pipeline
|
||||
- `xref/` makes the cross-reference extraction domain visible
|
||||
- `ingestion/` contains everything related to data fetching: the orchestrator, entity ingestors, AND their supporting DB operations
|
||||
- `cli/mod.rs` is lean — just the top-level Cli struct and Commands enum
|
||||
|
||||
A new developer (or coding agent) can now answer "where is the timeline code?" → `src/timeline/`, "where is ingestion?" → `src/ingestion/`, "where is cross-reference extraction?" → `src/xref/`, without needing institutional knowledge.
|
||||
287
README.md
287
README.md
@@ -12,6 +12,9 @@ Local GitLab data management with semantic search, people intelligence, and temp
|
||||
- **Hybrid search**: Combines FTS5 lexical search with Ollama-powered vector embeddings via Reciprocal Rank Fusion
|
||||
- **People intelligence**: Expert discovery, workload analysis, review patterns, active discussions, and code ownership overlap
|
||||
- **Timeline pipeline**: Reconstructs chronological event histories by combining search, graph traversal, and event aggregation across related entities
|
||||
- **Code provenance tracing**: Traces why code was introduced by linking files to MRs, MRs to issues, and issues to discussion threads
|
||||
- **File-level history**: Shows which MRs touched a file with rename-chain resolution and inline DiffNote snippets
|
||||
- **Surgical sync**: Sync specific issues or MRs by IID without running a full incremental sync, with preflight validation
|
||||
- **Git history linking**: Tracks merge and squash commit SHAs to connect MRs with git history
|
||||
- **File change tracking**: Records which files each MR touches, enabling file-level history queries
|
||||
- **Raw payload storage**: Preserves original GitLab API responses for debugging
|
||||
@@ -19,8 +22,14 @@ Local GitLab data management with semantic search, people intelligence, and temp
|
||||
- **Cross-reference tracking**: Automatic extraction of "closes", "mentioned" relationships between MRs and issues
|
||||
- **Work item status enrichment**: Fetches issue statuses (e.g., "To do", "In progress", "Done") from GitLab's GraphQL API with adaptive page sizing, color-coded display, and case-insensitive filtering
|
||||
- **Resource event history**: Tracks state changes, label events, and milestone events for issues and MRs
|
||||
- **Note querying**: Rich filtering over discussion notes by author, type, path, resolution status, time range, and body content
|
||||
- **Discussion drift detection**: Semantic analysis of how discussions diverge from original issue intent
|
||||
- **Automated sync scheduling**: Cron-based automatic syncing with configurable intervals (Unix)
|
||||
- **Token management**: Secure interactive or piped token storage with masked display
|
||||
- **Robot mode**: Machine-readable JSON output with structured errors, meaningful exit codes, and actionable recovery steps
|
||||
- **Error tolerance**: Auto-corrects common CLI mistakes (case, typos, single-dash flags, value casing) with teaching feedback
|
||||
- **Observability**: Verbosity controls, JSON log format, structured metrics, and stage timing
|
||||
- **Icon system**: Configurable icon sets (Nerd Fonts, Unicode, ASCII) with automatic detection
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -71,6 +80,21 @@ lore who @asmith
|
||||
# Timeline of events related to deployments
|
||||
lore timeline "deployment"
|
||||
|
||||
# Timeline for a specific issue
|
||||
lore timeline issue:42
|
||||
|
||||
# Why was this file changed? (file -> MR -> issue -> discussion)
|
||||
lore trace src/features/auth/login.ts
|
||||
|
||||
# Which MRs touched this file?
|
||||
lore file-history src/features/auth/
|
||||
|
||||
# Sync a specific issue without full sync
|
||||
lore sync --issue 42 -p group/repo
|
||||
|
||||
# Query notes by author
|
||||
lore notes --author alice --since 7d
|
||||
|
||||
# Robot mode (machine-readable JSON)
|
||||
lore -J issues -n 5 | jq .
|
||||
```
|
||||
@@ -109,6 +133,15 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
|
||||
"model": "nomic-embed-text",
|
||||
"baseUrl": "http://localhost:11434",
|
||||
"concurrency": 4
|
||||
},
|
||||
"scoring": {
|
||||
"authorWeight": 25,
|
||||
"reviewerWeight": 10,
|
||||
"noteBonus": 1,
|
||||
"authorHalfLifeDays": 180,
|
||||
"reviewerHalfLifeDays": 90,
|
||||
"noteHalfLifeDays": 45,
|
||||
"excludedUsernames": ["bot-user"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -135,6 +168,15 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
|
||||
| `embedding` | `model` | `nomic-embed-text` | Model name for embeddings |
|
||||
| `embedding` | `baseUrl` | `http://localhost:11434` | Ollama server URL |
|
||||
| `embedding` | `concurrency` | `4` | Concurrent embedding requests |
|
||||
| `scoring` | `authorWeight` | `25` | Points per MR where the user authored code touching the path |
|
||||
| `scoring` | `reviewerWeight` | `10` | Points per MR where the user reviewed code touching the path |
|
||||
| `scoring` | `noteBonus` | `1` | Bonus per inline review comment (DiffNote) |
|
||||
| `scoring` | `reviewerAssignmentWeight` | `3` | Points per MR where the user was assigned as reviewer |
|
||||
| `scoring` | `authorHalfLifeDays` | `180` | Half-life in days for author contribution decay |
|
||||
| `scoring` | `reviewerHalfLifeDays` | `90` | Half-life in days for reviewer contribution decay |
|
||||
| `scoring` | `noteHalfLifeDays` | `45` | Half-life in days for note/comment decay |
|
||||
| `scoring` | `closedMrMultiplier` | `0.5` | Score multiplier for closed (not merged) MRs |
|
||||
| `scoring` | `excludedUsernames` | `[]` | Usernames excluded from expert results (e.g., bots) |
|
||||
|
||||
### Config File Resolution
|
||||
|
||||
@@ -163,6 +205,8 @@ Create a personal access token with `read_api` scope:
|
||||
| `XDG_DATA_HOME` | XDG Base Directory for data (fallback: `~/.local/share`) | No |
|
||||
| `NO_COLOR` | Disable color output when set (any value) | No |
|
||||
| `CLICOLOR` | Standard color control (0 to disable) | No |
|
||||
| `LORE_ICONS` | Override icon set: `nerd`, `unicode`, or `ascii` | No |
|
||||
| `NERD_FONTS` | Enable Nerd Font icons when set to a non-empty value | No |
|
||||
| `RUST_LOG` | Logging level filter (e.g., `lore=debug`) | No |
|
||||
|
||||
## Commands
|
||||
@@ -262,18 +306,21 @@ lore search "login flow" --mode semantic # Vector similarity only
|
||||
lore search "auth" --type issue # Filter by source type
|
||||
lore search "auth" --type mr # MR documents only
|
||||
lore search "auth" --type discussion # Discussion documents only
|
||||
lore search "auth" --type note # Individual notes only
|
||||
lore search "deploy" --author username # Filter by author
|
||||
lore search "deploy" -p group/repo # Filter by project
|
||||
lore search "deploy" --label backend # Filter by label (AND logic)
|
||||
lore search "deploy" --path src/ # Filter by file path (trailing / for prefix)
|
||||
lore search "deploy" --after 7d # Created after (7d, 2w, 1m, or YYYY-MM-DD)
|
||||
lore search "deploy" --updated-after 2w # Updated after
|
||||
lore search "deploy" --since 7d # Created since (7d, 2w, 1m, or YYYY-MM-DD)
|
||||
lore search "deploy" --updated-since 2w # Updated since
|
||||
lore search "deploy" -n 50 # Limit results (default 20, max 100)
|
||||
lore search "deploy" --explain # Show ranking explanation per result
|
||||
lore search "deploy" --fts-mode raw # Raw FTS5 query syntax (advanced)
|
||||
```
|
||||
|
||||
The `--fts-mode` flag defaults to `safe`, which sanitizes user input into valid FTS5 queries with automatic fallback. Use `raw` for advanced FTS5 query syntax (AND, OR, NOT, phrase matching, prefix queries).
|
||||
The `--fts-mode` flag defaults to `safe`, which sanitizes user input into valid FTS5 queries with automatic fallback. FTS5 boolean operators (`AND`, `OR`, `NOT`, `NEAR`) are passed through in safe mode, so queries like `"switch AND health"` work without switching to raw mode. Use `raw` for advanced FTS5 query syntax (phrase matching, column filters, prefix queries).
|
||||
|
||||
A progress spinner displays during search, showing the active mode (e.g., `Searching (hybrid)...`). In robot mode, spinners are suppressed for clean JSON output.
|
||||
|
||||
Requires `lore generate-docs` (or `lore sync`) to have been run at least once. Semantic and hybrid modes require `lore embed` (or `lore sync`) to have generated vector embeddings via Ollama.
|
||||
|
||||
@@ -283,7 +330,7 @@ People intelligence: discover experts, analyze workloads, review patterns, activ
|
||||
|
||||
#### Expert Mode
|
||||
|
||||
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis).
|
||||
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis). Scores use exponential half-life decay so recent contributions count more than older ones. Scoring weights and half-life periods are configurable via the `scoring` config section.
|
||||
|
||||
```bash
|
||||
lore who src/features/auth/ # Who knows about this directory?
|
||||
@@ -292,6 +339,9 @@ lore who --path README.md # Root files need --path flag
|
||||
lore who --path Makefile # Dotless root files too
|
||||
lore who src/ --since 3m # Limit to recent 3 months
|
||||
lore who src/ -p group/repo # Scope to project
|
||||
lore who src/ --explain-score # Show per-component score breakdown
|
||||
lore who src/ --as-of 30d # Score as if "now" was 30 days ago
|
||||
lore who src/ --include-bots # Include bot users in results
|
||||
```
|
||||
|
||||
The target is auto-detected as a path when it contains `/`. For root files without `/` (e.g., `README.md`), use the `--path` flag. Default time window: 6 months.
|
||||
@@ -320,12 +370,13 @@ Shows: total DiffNotes, categorized by code area with percentage breakdown.
|
||||
|
||||
#### Active Mode
|
||||
|
||||
Surface unresolved discussions needing attention.
|
||||
Surface unresolved discussions needing attention. By default, only discussions on open issues and non-merged MRs are shown.
|
||||
|
||||
```bash
|
||||
lore who --active # Unresolved discussions (last 7 days)
|
||||
lore who --active --since 30d # Wider time window
|
||||
lore who --active -p group/repo # Scoped to project
|
||||
lore who --active --include-closed # Include discussions on closed/merged entities
|
||||
```
|
||||
|
||||
Shows: discussion threads with participants and last activity timestamps.
|
||||
@@ -348,21 +399,33 @@ Shows: users with touch counts (author vs. review), linked MR references. Defaul
|
||||
| `-p` / `--project` | Scope to a project (fuzzy match) |
|
||||
| `--since` | Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. |
|
||||
| `-n` / `--limit` | Max results per section (1-500, default 20) |
|
||||
| `--all-history` | Remove the default time window, query all history |
|
||||
| `--include-closed` | Include discussions on closed issues and merged/closed MRs (active mode) |
|
||||
| `--detail` | Show per-MR detail breakdown (expert mode only) |
|
||||
| `--explain-score` | Show per-component score breakdown (expert mode only) |
|
||||
| `--as-of` | Score as if "now" is a past date (ISO 8601 or duration like 30d, expert mode only) |
|
||||
| `--include-bots` | Include bot users normally excluded via `scoring.excludedUsernames` |
|
||||
|
||||
### `lore timeline`
|
||||
|
||||
Reconstruct a chronological timeline of events matching a keyword query. The pipeline discovers related entities through cross-reference graph traversal and assembles a unified, time-ordered event stream.
|
||||
|
||||
```bash
|
||||
lore timeline "deployment" # Events related to deployments
|
||||
lore timeline "deployment" # Search-based seeding (hybrid search)
|
||||
lore timeline issue:42 # Direct entity seeding by issue IID
|
||||
lore timeline i:42 # Shorthand for issue:42
|
||||
lore timeline mr:99 # Direct entity seeding by MR IID
|
||||
lore timeline m:99 # Shorthand for mr:99
|
||||
lore timeline "auth" -p group/repo # Scoped to a project
|
||||
lore timeline "auth" --since 30d # Only recent events
|
||||
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
|
||||
lore timeline "migration" --expand-mentions # Follow 'mentioned' edges (high fan-out)
|
||||
lore timeline "migration" --no-mentions # Skip 'mentioned' edges (reduces fan-out)
|
||||
lore timeline "deploy" -n 50 # Limit event count
|
||||
lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
||||
```
|
||||
|
||||
The query can be either a search string (hybrid search finds matching entities) or an entity reference (`issue:N`, `i:N`, `mr:N`, `m:N`) which directly seeds the timeline from a specific entity and its cross-references.
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
@@ -370,18 +433,21 @@ lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
|
||||
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
|
||||
| `--expand-mentions` | off | Also follow "mentioned" edges during expansion |
|
||||
| `--no-mentions` | off | Skip "mentioned" edges during expansion (reduces fan-out) |
|
||||
| `-n` / `--limit` | `100` | Maximum events to display |
|
||||
| `--max-seeds` | `10` | Maximum seed entities from search |
|
||||
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
|
||||
| `--max-evidence` | `10` | Maximum evidence notes included |
|
||||
| `--fields` | all | Select output fields (comma-separated, or 'minimal' preset) |
|
||||
|
||||
#### Pipeline Stages
|
||||
|
||||
1. **SEED** -- Full-text search identifies the most relevant issues and MRs matching the query. Documents are ranked by BM25 relevance.
|
||||
2. **HYDRATE** -- Evidence notes are extracted: the top FTS-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced.
|
||||
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and optionally "mentioned" references up to the configured depth.
|
||||
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, and evidence notes. Events are sorted chronologically with stable tiebreaking.
|
||||
Each stage displays a numbered progress spinner (e.g., `[1/3] Seeding timeline...`). In robot mode, spinners are suppressed for clean JSON output.
|
||||
|
||||
1. **SEED** -- Hybrid search (FTS5 lexical + Ollama vector similarity via Reciprocal Rank Fusion) identifies the most relevant issues and MRs. Falls back to lexical-only if Ollama is unavailable. Discussion notes matching the query are also discovered and attached to their parent entities.
|
||||
2. **HYDRATE** -- Evidence notes are extracted: the top search-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced. Matched discussions are collected as full thread candidates.
|
||||
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and "mentioned" references up to the configured depth. Use `--no-mentions` to exclude "mentioned" edges and reduce fan-out.
|
||||
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, evidence notes, and full discussion threads. Events are sorted chronologically with stable tiebreaking.
|
||||
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).
|
||||
|
||||
#### Event Types
|
||||
@@ -395,16 +461,139 @@ lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
||||
| `MilestoneSet` | Milestone assigned |
|
||||
| `MilestoneRemoved` | Milestone removed |
|
||||
| `Merged` | MR merged (deduplicated against state events) |
|
||||
| `NoteEvidence` | Discussion note matched by FTS, with snippet |
|
||||
| `NoteEvidence` | Discussion note matched by search, with snippet |
|
||||
| `DiscussionThread` | Full discussion thread with all non-system notes |
|
||||
| `CrossReferenced` | Reference to another entity |
|
||||
|
||||
#### Unresolved References
|
||||
|
||||
When graph expansion encounters cross-project references to entities not yet synced locally, these are collected as unresolved references in the output. This enables discovery of external dependencies and can inform future sync targets.
|
||||
|
||||
### `lore notes`
|
||||
|
||||
Query individual notes from discussions with rich filtering options.
|
||||
|
||||
```bash
|
||||
lore notes # List 50 most recent notes
|
||||
lore notes --author alice --since 7d # Notes by alice in last 7 days
|
||||
lore notes --for-issue 42 -p group/repo # Notes on issue #42
|
||||
lore notes --for-mr 99 -p group/repo # Notes on MR !99
|
||||
lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/
|
||||
lore notes --note-type DiffNote # Only inline code review comments
|
||||
lore notes --contains "TODO" # Substring search in note body
|
||||
lore notes --include-system # Include system-generated notes
|
||||
lore notes --since 2w --until 2024-12-31 # Time-bounded range
|
||||
lore notes --sort updated --asc # Sort by update time, ascending
|
||||
lore notes -o # Open first result in browser
|
||||
|
||||
# Field selection (robot mode)
|
||||
lore -J notes --fields minimal # Compact: id, author_username, body, created_at_iso
|
||||
```
|
||||
|
||||
#### Filters
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-a` / `--author` | Filter by note author username |
|
||||
| `--note-type` | Filter by note type (DiffNote, DiscussionNote) |
|
||||
| `--contains` | Substring search in note body |
|
||||
| `--note-id` | Filter by internal note ID |
|
||||
| `--gitlab-note-id` | Filter by GitLab note ID |
|
||||
| `--discussion-id` | Filter by discussion ID |
|
||||
| `--include-system` | Include system notes (excluded by default) |
|
||||
| `--for-issue` | Notes on a specific issue IID (requires `-p`) |
|
||||
| `--for-mr` | Notes on a specific MR IID (requires `-p`) |
|
||||
| `-p` / `--project` | Scope to a project (fuzzy match) |
|
||||
| `--since` | Notes created since (7d, 2w, 1m, or YYYY-MM-DD) |
|
||||
| `--until` | Notes created until (YYYY-MM-DD, inclusive end-of-day) |
|
||||
| `--path` | Filter by file path (DiffNotes only; trailing `/` for prefix match) |
|
||||
| `--resolution` | Filter by resolution status (`any`, `unresolved`, `resolved`) |
|
||||
| `--sort` | Sort by `created` (default) or `updated` |
|
||||
| `--asc` | Sort ascending (default: descending) |
|
||||
| `-o` / `--open` | Open first result in browser |
|
||||
|
||||
### `lore file-history`
|
||||
|
||||
Show which merge requests touched a file, with rename-chain resolution and optional DiffNote discussion snippets.
|
||||
|
||||
```bash
|
||||
lore file-history src/main.rs # MRs that touched this file
|
||||
lore file-history src/auth/ -p group/repo # Scoped to project
|
||||
lore file-history src/foo.rs --discussions # Include DiffNote snippets
|
||||
lore file-history src/bar.rs --no-follow-renames # Skip rename chain resolution
|
||||
lore file-history src/bar.rs --merged # Only merged MRs
|
||||
lore file-history src/bar.rs -n 100 # More results
|
||||
```
|
||||
|
||||
Rename-chain resolution follows file renames through `mr_file_changes` so that querying a renamed file also surfaces MRs that touched previous names. Disable with `--no-follow-renames`.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||
| `--discussions` | off | Include DiffNote discussion snippets on the file |
|
||||
| `--no-follow-renames` | off | Disable rename chain resolution |
|
||||
| `--merged` | off | Only show merged MRs |
|
||||
| `-n` / `--limit` | `50` | Maximum results |
|
||||
|
||||
### `lore trace`
|
||||
|
||||
Trace why code was introduced by building provenance chains: file -> MR -> issue -> discussion threads.
|
||||
|
||||
```bash
|
||||
lore trace src/main.rs # Why was this file changed?
|
||||
lore trace src/auth/ -p group/repo # Scoped to project
|
||||
lore trace src/foo.rs --discussions # Include DiffNote context
|
||||
lore trace src/bar.rs:42 # Line hint (future Tier 2)
|
||||
lore trace src/bar.rs --no-follow-renames # Skip rename chain resolution
|
||||
```
|
||||
|
||||
Each trace chain links a file change to the MR that introduced it, the issue(s) that motivated it (via "closes" references), and the discussion threads on those entities. Line-level hints (`:line` suffix) are accepted but produce an advisory message until Tier 2 git-blame integration is available.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||
| `--discussions` | off | Include DiffNote discussion snippets |
|
||||
| `--no-follow-renames` | off | Disable rename chain resolution |
|
||||
| `-n` / `--limit` | `20` | Maximum trace chains to display |
|
||||
|
||||
### `lore drift`
|
||||
|
||||
Detect discussion divergence from the original intent of an issue by comparing the semantic similarity of discussion content against the issue description.
|
||||
|
||||
```bash
|
||||
lore drift issues 42 # Check divergence on issue #42
|
||||
lore drift issues 42 --threshold 0.6 # Higher threshold (stricter)
|
||||
lore drift issues 42 -p group/repo # Scope to project
|
||||
```
|
||||
|
||||
### `lore cron`
|
||||
|
||||
Manage cron-based automatic syncing (Unix only). Installs a crontab entry that runs `lore sync --lock -q` at a configurable interval.
|
||||
|
||||
```bash
|
||||
lore cron install # Install cron job (every 8 minutes)
|
||||
lore cron install --interval 15 # Custom interval in minutes
|
||||
lore cron status # Check if cron is installed
|
||||
lore cron uninstall # Remove cron job
|
||||
```
|
||||
|
||||
The `--lock` flag on the auto-sync ensures that if a sync is already running, the cron invocation exits cleanly rather than competing for the database lock.
|
||||
|
||||
### `lore token`
|
||||
|
||||
Manage the stored GitLab token. Supports interactive entry with validation, non-interactive piped input, and masked display.
|
||||
|
||||
```bash
|
||||
lore token set # Interactive token entry + validation
|
||||
lore token set --token glpat-xxx # Non-interactive token storage
|
||||
echo glpat-xxx | lore token set # Pipe token from stdin
|
||||
lore token show # Show token (masked)
|
||||
lore token show --unmask # Show full token
|
||||
```
|
||||
|
||||
### `lore sync`
|
||||
|
||||
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings.
|
||||
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings. Supports both incremental (cursor-based) and surgical (per-IID) modes.
|
||||
|
||||
```bash
|
||||
lore sync # Full pipeline
|
||||
@@ -413,11 +602,30 @@ lore sync --force # Override stale lock
|
||||
lore sync --no-embed # Skip embedding step
|
||||
lore sync --no-docs # Skip document regeneration
|
||||
lore sync --no-events # Skip resource event fetching
|
||||
lore sync --no-file-changes # Skip MR file change fetching
|
||||
lore sync --no-status # Skip work-item status enrichment via GraphQL
|
||||
lore sync --dry-run # Preview what would be synced
|
||||
lore sync --timings # Show detailed timing breakdown per stage
|
||||
lore sync --lock # Acquire file lock (skip if another sync is running)
|
||||
|
||||
# Surgical sync: fetch specific entities by IID
|
||||
lore sync --issue 42 -p group/repo # Sync a single issue
|
||||
lore sync --mr 99 -p group/repo # Sync a single MR
|
||||
lore sync --issue 42 --mr 99 -p group/repo # Mix issues and MRs
|
||||
lore sync --issue 1 --issue 2 -p group/repo # Multiple issues
|
||||
lore sync --issue 42 -p group/repo --preflight-only # Validate without writing
|
||||
```
|
||||
|
||||
The sync command displays animated progress bars for each stage and outputs timing metrics on completion. In robot mode (`-J`), detailed stage timing is included in the JSON response.
|
||||
|
||||
#### Surgical Sync
|
||||
|
||||
When `--issue` or `--mr` flags are provided, sync switches to surgical mode which fetches only the specified entities and their dependents (discussions, events, file changes) from GitLab. This is faster than a full incremental sync and useful for refreshing specific entities on demand.
|
||||
|
||||
Surgical mode requires `-p` / `--project` to scope the operation. Each entity goes through preflight validation against the GitLab API, then ingestion, document regeneration, and embedding. Entities that haven't changed since the last sync are skipped (TOCTOU check).
|
||||
|
||||
Use `--preflight-only` to validate that entities exist on GitLab without writing to the database.
|
||||
|
||||
### `lore ingest`
|
||||
|
||||
Sync data from GitLab to local database. Runs only the ingestion step (no doc generation or embeddings). For issue ingestion, this includes a status enrichment phase that fetches work item statuses via the GitLab GraphQL API.
|
||||
@@ -571,6 +779,7 @@ Machine-readable command manifest for agent self-discovery. Returns a JSON schem
|
||||
```bash
|
||||
lore robot-docs # Pretty-printed JSON
|
||||
lore --robot robot-docs # Compact JSON for parsing
|
||||
lore robot-docs --brief # Omit response_schema (~60% smaller)
|
||||
```
|
||||
|
||||
### `lore version`
|
||||
@@ -622,7 +831,7 @@ The `actions` array contains executable shell commands an agent can run to recov
|
||||
|
||||
### Field Selection
|
||||
|
||||
The `--fields` flag on `issues` and `mrs` list commands controls which fields appear in the JSON response, reducing token usage for AI agent workflows:
|
||||
The `--fields` flag controls which fields appear in the JSON response, reducing token usage for AI agent workflows. Supported on `issues`, `mrs`, `notes`, `search`, `timeline`, and `who` list commands:
|
||||
|
||||
```bash
|
||||
# Minimal preset (~60% fewer tokens)
|
||||
@@ -639,6 +848,48 @@ Valid fields for issues: `iid`, `title`, `state`, `author_username`, `labels`, `
|
||||
|
||||
Valid fields for MRs: `iid`, `title`, `state`, `author_username`, `labels`, `draft`, `target_branch`, `source_branch`, `discussion_count`, `unresolved_count`, `created_at_iso`, `updated_at_iso`, `web_url`, `project_path`, `reviewers`
|
||||
|
||||
### Error Tolerance
|
||||
|
||||
The CLI auto-corrects common mistakes before parsing, emitting a teaching note to stderr. Corrections work in both human and robot modes:
|
||||
|
||||
| Correction | Example | Mode |
|
||||
|-----------|---------|------|
|
||||
| Single-dash long flag | `-robot` -> `--robot` | All |
|
||||
| Case normalization | `--Robot` -> `--robot` | All |
|
||||
| Flag prefix expansion | `--proj` -> `--project`, `--no-color` -> `--color never` (unambiguous only) | All |
|
||||
| Fuzzy flag match | `--projct` -> `--project` | All (threshold 0.9 in robot, 0.8 in human) |
|
||||
| Subcommand alias | `merge_requests` -> `mrs`, `robotdocs` -> `robot-docs` | All |
|
||||
| Value normalization | `--state Opened` -> `--state opened` | All |
|
||||
| Value fuzzy match | `--state opend` -> `--state opened` | All |
|
||||
| Subcommand prefix | `lore iss` -> `lore issues` (unambiguous only, via clap) | All |
|
||||
|
||||
In robot mode, corrections emit structured JSON to stderr:
|
||||
|
||||
```json
|
||||
{"warning":{"type":"ARG_CORRECTED","corrections":[...],"teaching":["Use double-dash for long flags: --robot (not -robot)"]}}
|
||||
```
|
||||
|
||||
When a command or flag is still unrecognized after corrections, the error response includes a fuzzy suggestion and, for enum-like flags, lists valid values:
|
||||
|
||||
```json
|
||||
{"error":{"code":"UNKNOWN_COMMAND","message":"...","suggestion":"Did you mean 'lore issues'? Example: lore --robot issues -n 10. Run 'lore robot-docs' for all commands"}}
|
||||
```
|
||||
|
||||
### Command Aliases
|
||||
|
||||
Commands accept aliases for common variations:
|
||||
|
||||
| Primary | Aliases |
|
||||
|---------|---------|
|
||||
| `issues` | `issue` |
|
||||
| `mrs` | `mr`, `merge-requests`, `merge-request` |
|
||||
| `notes` | `note` |
|
||||
| `search` | `find`, `query` |
|
||||
| `stats` | `stat` |
|
||||
| `status` | `st` |
|
||||
|
||||
Unambiguous prefixes also work via subcommand inference (e.g., `lore iss` -> `lore issues`, `lore time` -> `lore timeline`, `lore tra` -> `lore trace`).
|
||||
|
||||
### Agent Self-Discovery
|
||||
|
||||
The `robot-docs` command provides a complete machine-readable manifest including response schemas for every command:
|
||||
@@ -692,6 +943,8 @@ lore --robot <command> # Machine-readable JSON
|
||||
lore -J <command> # JSON shorthand
|
||||
lore --color never <command> # Disable color output
|
||||
lore --color always <command> # Force color output
|
||||
lore --icons nerd <command> # Nerd Font icons
|
||||
lore --icons ascii <command> # ASCII-only icons (no Unicode)
|
||||
lore -q <command> # Suppress non-essential output
|
||||
lore -v <command> # Debug logging
|
||||
lore -vv <command> # More verbose debug logging
|
||||
@@ -699,7 +952,7 @@ lore -vvv <command> # Trace-level logging
|
||||
lore --log-format json <command> # JSON-formatted log output to stderr
|
||||
```
|
||||
|
||||
Color output respects `NO_COLOR` and `CLICOLOR` environment variables in `auto` mode (the default).
|
||||
Color output respects `NO_COLOR` and `CLICOLOR` environment variables in `auto` mode (the default). Icon sets default to `unicode` and can be overridden via `--icons`, `LORE_ICONS`, or `NERD_FONTS` environment variables.
|
||||
|
||||
## Shell Completions
|
||||
|
||||
@@ -747,7 +1000,7 @@ Data is stored in SQLite with WAL mode and foreign keys enabled. Main tables:
|
||||
| `embeddings` | Vector embeddings for semantic search |
|
||||
| `dirty_sources` | Entities needing document regeneration after ingest |
|
||||
| `pending_discussion_fetches` | Queue for discussion fetch operations |
|
||||
| `sync_runs` | Audit trail of sync operations |
|
||||
| `sync_runs` | Audit trail of sync operations (supports surgical mode tracking with per-entity results) |
|
||||
| `sync_cursors` | Cursor positions for incremental sync |
|
||||
| `app_locks` | Crash-safe single-flight lock |
|
||||
| `raw_payloads` | Compressed original API responses |
|
||||
|
||||
64
acceptance-criteria.md
Normal file
64
acceptance-criteria.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Trace/File-History Empty-Result Diagnostics
|
||||
|
||||
## AC-1: Human mode shows searched paths on empty results
|
||||
|
||||
When `lore trace <path>` returns 0 chains in human mode, the output includes the resolved path(s) that were searched. If renames were followed, show the full rename chain.
|
||||
|
||||
## AC-2: Human mode shows actionable reason on empty results
|
||||
|
||||
When 0 chains are found, the hint message distinguishes between:
|
||||
- "No MR file changes synced yet" (mr_file_changes table is empty for this project) -> suggest `lore sync`
|
||||
- "File paths not found in MR file changes" (sync has run but this file has no matches) -> suggest checking the path or that the file may predate the sync window
|
||||
|
||||
## AC-3: Robot mode includes diagnostics object on empty results
|
||||
|
||||
When `total_chains == 0` in robot JSON output, add a `"diagnostics"` key to `"meta"` containing:
|
||||
- `paths_searched: [...]` (already present as `resolved_paths` in data -- no duplication needed)
|
||||
- `hints: [string]` -- same actionable reasons as AC-2 but machine-readable
|
||||
|
||||
## AC-4: Info-level logging at each pipeline stage
|
||||
|
||||
Add `tracing::info!` calls visible with `-v`:
|
||||
- After rename resolution: number of paths found
|
||||
- After MR query: number of MRs found
|
||||
- After issue/discussion enrichment: counts per MR
|
||||
|
||||
## AC-5: Apply same pattern to `lore file-history`
|
||||
|
||||
All of the above (AC-1 through AC-4) also apply to `lore file-history` empty results.
|
||||
|
||||
---
|
||||
|
||||
# Secure Token Resolution for Cron
|
||||
|
||||
## AC-6: Stored token in config
|
||||
|
||||
The configuration file supports an optional `token` field in the `gitlab` section, allowing users to persist their GitLab personal access token alongside other settings. Existing configuration files that omit this field continue to load and function normally.
|
||||
|
||||
## AC-7: Token resolution precedence
|
||||
|
||||
Lore resolves the GitLab token by checking the environment variable first, then falling back to the stored config token. This means environment variables always take priority, preserving CI/CD workflows and one-off overrides, while the stored token provides a reliable default for non-interactive contexts like cron jobs. If neither source provides a non-empty value, the user receives a clear `TOKEN_NOT_SET` error with guidance on how to fix it.
|
||||
|
||||
## AC-8: `lore token set` command
|
||||
|
||||
The `lore token set` command provides a secure, guided workflow for storing a GitLab token. It accepts the token via a `--token` flag, standard input (for piped automation), or an interactive masked prompt. Before storing, it validates the token against the GitLab API to catch typos and expired credentials early. After writing the token to the configuration file, it restricts file permissions to owner-only read/write (mode 0600) to prevent other users on the system from reading the token. The command supports both human and robot output modes.
|
||||
|
||||
## AC-9: `lore token show` command
|
||||
|
||||
The `lore token show` command displays the currently active token along with its source ("config file" or "environment variable"). By default the token value is masked for safety; the `--unmask` flag reveals the full value when needed. The command supports both human and robot output modes.
|
||||
|
||||
## AC-10: Consistent token resolution across all commands
|
||||
|
||||
Every command that requires a GitLab token uses the same two-step resolution logic described in AC-7. This ensures that storing a token once via `lore token set` is sufficient to make all commands work, including background cron syncs that have no access to shell environment variables.
|
||||
|
||||
## AC-11: Cron install warns about missing stored token
|
||||
|
||||
When `lore cron install` completes, it checks whether a token is available in the configuration file. If not, it displays a prominent warning explaining that cron jobs cannot access shell environment variables and directs the user to run `lore token set` to ensure unattended syncs will authenticate successfully.
|
||||
|
||||
## AC-12: `TOKEN_NOT_SET` error recommends `lore token set`
|
||||
|
||||
The `TOKEN_NOT_SET` error message recommends `lore token set` as the primary fix for missing credentials, with the environment variable export shown as an alternative for users who prefer that approach. In robot mode, the `actions` array lists both options so that automated recovery workflows can act on them.
|
||||
|
||||
## AC-13: Doctor reports token source
|
||||
|
||||
The `lore doctor` command includes the token's source in its GitLab connectivity check, reporting whether the token was found in the configuration file or an environment variable. This makes it straightforward to verify that cron jobs will have access to the token without relying on the user's interactive shell environment.
|
||||
1251
docs/command-surface-analysis.md
Normal file
1251
docs/command-surface-analysis.md
Normal file
File diff suppressed because it is too large
Load Diff
92
docs/command-surface-analysis/00-overview.md
Normal file
92
docs/command-surface-analysis/00-overview.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Lore Command Surface Analysis — Overview
|
||||
|
||||
**Date:** 2026-02-26
|
||||
**Version:** v0.9.1 (439c20e)
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Deep analysis of the full `lore` CLI command surface: what each command does, how commands overlap, how they connect in agent workflows, and where consolidation and robot-mode optimization can reduce round trips and token waste.
|
||||
|
||||
## Document Map
|
||||
|
||||
| File | Contents | When to Read |
|
||||
|---|---|---|
|
||||
| **00-overview.md** | This file. Summary, inventory, priorities. | Always read first. |
|
||||
| [01-entity-commands.md](01-entity-commands.md) | `issues`, `mrs`, `notes`, `search`, `count` — flags, DB tables, robot schemas | Need command reference for entity queries |
|
||||
| [02-intelligence-commands.md](02-intelligence-commands.md) | `who`, `timeline`, `me`, `file-history`, `trace`, `related`, `drift` | Need command reference for intelligence/analysis |
|
||||
| [03-pipeline-and-infra.md](03-pipeline-and-infra.md) | `sync`, `ingest`, `generate-docs`, `embed`, diagnostics, setup | Need command reference for data management |
|
||||
| [04-data-flow.md](04-data-flow.md) | Shared data source map, command network graph, clusters | Understanding how commands interconnect |
|
||||
| [05-overlap-analysis.md](05-overlap-analysis.md) | Quantified overlap percentages for every command pair | Evaluating what to consolidate |
|
||||
| [06-agent-workflows.md](06-agent-workflows.md) | Common agent flows, round-trip costs, token profiles | Understanding inefficiency pain points |
|
||||
| [07-consolidation-proposals.md](07-consolidation-proposals.md) | 5 proposals to reduce 34 commands to 29 | Planning command surface changes |
|
||||
| [08-robot-optimization-proposals.md](08-robot-optimization-proposals.md) | 6 proposals for `--include`, `--batch`, `--depth`, etc. | Planning robot-mode improvements |
|
||||
| [09-appendices.md](09-appendices.md) | Robot output envelope, field presets, exit codes | Reference material |
|
||||
|
||||
---
|
||||
|
||||
## Command Inventory (34 commands)
|
||||
|
||||
| Category | Commands | Count |
|
||||
|---|---|---|
|
||||
| Entity Query | `issues`, `mrs`, `notes`, `search`, `count` | 5 |
|
||||
| Intelligence | `who` (5 modes), `timeline`, `related`, `drift`, `me`, `file-history`, `trace` | 7 (11 with who sub-modes) |
|
||||
| Data Pipeline | `sync`, `ingest`, `generate-docs`, `embed` | 4 |
|
||||
| Diagnostics | `health`, `auth`, `doctor`, `status`, `stats` | 5 |
|
||||
| Setup | `init`, `token`, `cron`, `migrate` | 4 |
|
||||
| Meta | `version`, `completions`, `robot-docs` | 3 |
|
||||
|
||||
---
|
||||
|
||||
## Key Findings
|
||||
|
||||
### High-Overlap Pairs
|
||||
|
||||
| Pair | Overlap | Recommendation |
|
||||
|---|---|---|
|
||||
| `who workload` vs `me` | ~85% | Workload is a strict subset of me |
|
||||
| `health` vs `doctor` | ~90% | Health is a strict subset of doctor |
|
||||
| `file-history` vs `trace` | ~75% | Trace is a superset minus `--merged` |
|
||||
| `related` query-mode vs `search --mode semantic` | ~80% | Related query-mode is search without filters |
|
||||
| `auth` vs `doctor` | ~100% of auth | Auth is fully contained within doctor |
|
||||
|
||||
### Agent Workflow Pain Points
|
||||
|
||||
| Workflow | Current Round Trips | With Optimizations |
|
||||
|---|---|---|
|
||||
| "Understand this issue" | 4 calls | 1 call (`--include`) |
|
||||
| "Why was code changed?" | 3 calls | 1 call (`--include`) |
|
||||
| "What should I work on?" | 4 calls | 2 calls |
|
||||
| "Find and understand" | 4 calls | 2 calls |
|
||||
| "Is system healthy?" | 2-4 calls | 1 call |
|
||||
|
||||
---
|
||||
|
||||
## Priority Ranking
|
||||
|
||||
| Pri | Proposal | Category | Effort | Impact |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | `--include` flag on detail commands | Robot optimization | High | Eliminates 2-3 round trips per workflow |
|
||||
| **P0** | `--depth` on `me` command | Robot optimization | Low | 60-80% token reduction on most-used command |
|
||||
| **P1** | `--batch` for detail views | Robot optimization | Medium | Eliminates N+1 after search/timeline |
|
||||
| **P1** | Absorb `file-history` into `trace` | Consolidation | Low | Cleaner surface, shared code |
|
||||
| **P1** | Merge `who overlap` into `who expert` | Consolidation | Low | -1 round trip in review flows |
|
||||
| **P2** | `context` composite command | Robot optimization | Medium | Single entry point for entity understanding |
|
||||
| **P2** | Merge `count`+`status` into `stats` | Consolidation | Medium | -2 commands, progressive disclosure |
|
||||
| **P2** | Absorb `auth` into `doctor` | Consolidation | Low | -1 command |
|
||||
| **P2** | Remove `related` query-mode | Consolidation | Low | -1 confusing choice |
|
||||
| **P3** | `--max-tokens` budget | Robot optimization | High | Flexible but complex to implement |
|
||||
| **P3** | `--format tsv` | Robot optimization | Medium | High savings, limited applicability |
|
||||
|
||||
### Consolidation Summary
|
||||
|
||||
| Before | After | Removed |
|
||||
|---|---|---|
|
||||
| `file-history` + `trace` | `trace` (+ `--shallow`) | -1 |
|
||||
| `auth` + `doctor` | `doctor` (+ `--auth`) | -1 |
|
||||
| `related` query-mode | `search --mode semantic` | -1 mode |
|
||||
| `who overlap` + `who expert` | `who expert` (+ touch_count) | -1 sub-mode |
|
||||
| `count` + `status` + `stats` | `stats` (+ `--entities`, `--sync`) | -2 |
|
||||
|
||||
**Total: 34 commands -> 29 commands**
|
||||
308
docs/command-surface-analysis/01-entity-commands.md
Normal file
308
docs/command-surface-analysis/01-entity-commands.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Entity Query Commands
|
||||
|
||||
Reference for: `issues`, `mrs`, `notes`, `search`, `count`
|
||||
|
||||
---
|
||||
|
||||
## `issues` (alias: `issue`)
|
||||
|
||||
List or show issues from local database.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `[IID]` | positional | — | Omit to list, provide to show detail |
|
||||
| `-n, --limit` | int | 50 | Max results |
|
||||
| `--fields` | string | — | Select output columns (preset: `minimal`) |
|
||||
| `-s, --state` | enum | — | `opened\|closed\|all` |
|
||||
| `-p, --project` | string | — | Filter by project (fuzzy) |
|
||||
| `-a, --author` | string | — | Filter by author username |
|
||||
| `-A, --assignee` | string | — | Filter by assignee username |
|
||||
| `-l, --label` | string[] | — | Filter by labels (AND logic, repeatable) |
|
||||
| `-m, --milestone` | string | — | Filter by milestone title |
|
||||
| `--status` | string[] | — | Filter by work-item status (COLLATE NOCASE, OR logic) |
|
||||
| `--since` | duration/date | — | Filter by created date (`7d`, `2w`, `YYYY-MM-DD`) |
|
||||
| `--due-before` | date | — | Filter by due date |
|
||||
| `--has-due` | flag | — | Show only issues with due dates |
|
||||
| `--sort` | enum | `updated` | `updated\|created\|iid` |
|
||||
| `--asc` | flag | — | Sort ascending |
|
||||
| `-o, --open` | flag | — | Open first match in browser |
|
||||
|
||||
**DB tables:** `issues`, `projects`, `issue_assignees`, `issue_labels`, `labels`
|
||||
**Detail mode adds:** `discussions`, `notes`, `entity_references` (closing MRs)
|
||||
|
||||
### Robot Output (list mode)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"issues": [
|
||||
{
|
||||
"iid": 42, "title": "Fix auth", "state": "opened",
|
||||
"author_username": "jdoe", "labels": ["backend"],
|
||||
"assignees": ["jdoe"], "discussion_count": 3,
|
||||
"unresolved_count": 1, "created_at_iso": "...",
|
||||
"updated_at_iso": "...", "web_url": "...",
|
||||
"project_path": "group/repo",
|
||||
"status_name": "In progress"
|
||||
}
|
||||
],
|
||||
"total_count": 150, "showing": 50
|
||||
},
|
||||
"meta": { "elapsed_ms": 40, "available_statuses": ["Open", "In progress", "Closed"] }
|
||||
}
|
||||
```
|
||||
|
||||
### Robot Output (detail mode — `issues <IID>`)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 12345, "iid": 42, "title": "Fix auth",
|
||||
"description": "Full markdown body...",
|
||||
"state": "opened", "author_username": "jdoe",
|
||||
"created_at": "...", "updated_at": "...", "closed_at": null,
|
||||
"confidential": false, "web_url": "...", "project_path": "group/repo",
|
||||
"references_full": "group/repo#42",
|
||||
"labels": ["backend"], "assignees": ["jdoe"],
|
||||
"due_date": null, "milestone": null,
|
||||
"user_notes_count": 5, "merge_requests_count": 1,
|
||||
"closing_merge_requests": [
|
||||
{ "iid": 99, "title": "Refactor auth", "state": "merged", "web_url": "..." }
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"notes": [
|
||||
{ "author_username": "jdoe", "body": "...", "created_at": "...", "is_system": false }
|
||||
],
|
||||
"individual_note": false
|
||||
}
|
||||
],
|
||||
"status_name": "In progress", "status_color": "#1068bf"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal preset:** `iid`, `title`, `state`, `updated_at_iso`
|
||||
|
||||
---
|
||||
|
||||
## `mrs` (aliases: `mr`, `merge-request`, `merge-requests`)
|
||||
|
||||
List or show merge requests.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `[IID]` | positional | — | Omit to list, provide to show detail |
|
||||
| `-n, --limit` | int | 50 | Max results |
|
||||
| `--fields` | string | — | Select output columns (preset: `minimal`) |
|
||||
| `-s, --state` | enum | — | `opened\|merged\|closed\|locked\|all` |
|
||||
| `-p, --project` | string | — | Filter by project |
|
||||
| `-a, --author` | string | — | Filter by author |
|
||||
| `-A, --assignee` | string | — | Filter by assignee |
|
||||
| `-r, --reviewer` | string | — | Filter by reviewer |
|
||||
| `-l, --label` | string[] | — | Filter by labels (AND) |
|
||||
| `--since` | duration/date | — | Filter by created date |
|
||||
| `-d, --draft` | flag | — | Draft MRs only |
|
||||
| `-D, --no-draft` | flag | — | Exclude drafts |
|
||||
| `--target` | string | — | Filter by target branch |
|
||||
| `--source` | string | — | Filter by source branch |
|
||||
| `--sort` | enum | `updated` | `updated\|created\|iid` |
|
||||
| `--asc` | flag | — | Sort ascending |
|
||||
| `-o, --open` | flag | — | Open in browser |
|
||||
|
||||
**DB tables:** `merge_requests`, `projects`, `mr_reviewers`, `mr_labels`, `labels`, `mr_assignees`
|
||||
**Detail mode adds:** `discussions`, `notes`, `mr_diffs`
|
||||
|
||||
### Robot Output (list mode)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"mrs": [
|
||||
{
|
||||
"iid": 99, "title": "Refactor auth", "state": "merged",
|
||||
"draft": false, "author_username": "jdoe",
|
||||
"source_branch": "feat/auth", "target_branch": "main",
|
||||
"labels": ["backend"], "assignees": ["jdoe"], "reviewers": ["reviewer"],
|
||||
"discussion_count": 5, "unresolved_count": 0,
|
||||
"created_at_iso": "...", "updated_at_iso": "...",
|
||||
"web_url": "...", "project_path": "group/repo"
|
||||
}
|
||||
],
|
||||
"total_count": 500, "showing": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Robot Output (detail mode — `mrs <IID>`)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 67890, "iid": 99, "title": "Refactor auth",
|
||||
"description": "Full markdown body...",
|
||||
"state": "merged", "draft": false, "author_username": "jdoe",
|
||||
"source_branch": "feat/auth", "target_branch": "main",
|
||||
"created_at": "...", "updated_at": "...",
|
||||
"merged_at": "...", "closed_at": null,
|
||||
"web_url": "...", "project_path": "group/repo",
|
||||
"labels": ["backend"], "assignees": ["jdoe"], "reviewers": ["reviewer"],
|
||||
"discussions": [
|
||||
{
|
||||
"notes": [
|
||||
{
|
||||
"author_username": "reviewer", "body": "...",
|
||||
"created_at": "...", "is_system": false,
|
||||
"position": { "new_path": "src/auth.rs", "new_line": 42 }
|
||||
}
|
||||
],
|
||||
"individual_note": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal preset:** `iid`, `title`, `state`, `updated_at_iso`
|
||||
|
||||
---
|
||||
|
||||
## `notes` (alias: `note`)
|
||||
|
||||
List discussion notes/comments with fine-grained filters.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `-n, --limit` | int | 50 | Max results |
|
||||
| `--fields` | string | — | Preset: `minimal` |
|
||||
| `-a, --author` | string | — | Filter by author |
|
||||
| `--note-type` | enum | — | `DiffNote\|DiscussionNote` |
|
||||
| `--contains` | string | — | Body text substring filter |
|
||||
| `--note-id` | int | — | Internal note ID |
|
||||
| `--gitlab-note-id` | int | — | GitLab note ID |
|
||||
| `--discussion-id` | string | — | Discussion ID filter |
|
||||
| `--include-system` | flag | — | Include system notes |
|
||||
| `--for-issue` | int | — | Notes on specific issue (requires `-p`) |
|
||||
| `--for-mr` | int | — | Notes on specific MR (requires `-p`) |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
| `--since` | duration/date | — | Created after |
|
||||
| `--until` | date | — | Created before (inclusive) |
|
||||
| `--path` | string | — | File path filter (exact or prefix with `/`) |
|
||||
| `--resolution` | enum | — | `any\|unresolved\|resolved` |
|
||||
| `--sort` | enum | `created` | `created\|updated` |
|
||||
| `--asc` | flag | — | Sort ascending |
|
||||
| `--open` | flag | — | Open in browser |
|
||||
|
||||
**DB tables:** `notes`, `discussions`, `projects`, `issues`, `merge_requests`
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"notes": [
|
||||
{
|
||||
"id": 1234, "gitlab_id": 56789,
|
||||
"author_username": "reviewer", "body": "...",
|
||||
"note_type": "DiffNote", "is_system": false,
|
||||
"created_at_iso": "...", "updated_at_iso": "...",
|
||||
"position_new_path": "src/auth.rs", "position_new_line": 42,
|
||||
"resolvable": true, "resolved": false,
|
||||
"noteable_type": "MergeRequest", "parent_iid": 99,
|
||||
"parent_title": "Refactor auth", "project_path": "group/repo"
|
||||
}
|
||||
],
|
||||
"total_count": 1000, "showing": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal preset:** `id`, `author_username`, `body`, `created_at_iso`
|
||||
|
||||
---
|
||||
|
||||
## `search` (aliases: `find`, `query`)
|
||||
|
||||
Semantic + full-text search across indexed documents.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<QUERY>` | positional | required | Search query string |
|
||||
| `--mode` | enum | `hybrid` | `lexical\|hybrid\|semantic` |
|
||||
| `--type` | enum | — | `issue\|mr\|discussion\|note` |
|
||||
| `--author` | string | — | Filter by author |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
| `--label` | string[] | — | Filter by labels (AND) |
|
||||
| `--path` | string | — | File path filter |
|
||||
| `--since` | duration/date | — | Created after |
|
||||
| `--updated-since` | duration/date | — | Updated after |
|
||||
| `-n, --limit` | int | 20 | Max results (max: 100) |
|
||||
| `--fields` | string | — | Preset: `minimal` |
|
||||
| `--explain` | flag | — | Show ranking breakdown |
|
||||
| `--fts-mode` | enum | `safe` | `safe\|raw` |
|
||||
|
||||
**DB tables:** `documents`, `documents_fts` (FTS5), `embeddings` (vec0), `document_labels`, `document_paths`, `projects`
|
||||
|
||||
**Search modes:**
|
||||
- **lexical** — FTS5 with BM25 ranking (fastest, no Ollama needed)
|
||||
- **hybrid** — RRF combination of lexical + semantic (default)
|
||||
- **semantic** — Vector similarity only (requires Ollama)
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"query": "authentication bug",
|
||||
"mode": "hybrid",
|
||||
"total_results": 15,
|
||||
"results": [
|
||||
{
|
||||
"document_id": 1234, "source_type": "issue",
|
||||
"title": "Fix SSO auth", "url": "...",
|
||||
"author": "jdoe", "project_path": "group/repo",
|
||||
"labels": ["auth"], "paths": ["src/auth/"],
|
||||
"snippet": "...matching text...",
|
||||
"score": 0.85,
|
||||
"explain": { "vector_rank": 2, "fts_rank": 1, "rrf_score": 0.85 }
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal preset:** `document_id`, `title`, `source_type`, `score`
|
||||
|
||||
---
|
||||
|
||||
## `count`
|
||||
|
||||
Count entities in local database.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<ENTITY>` | positional | required | `issues\|mrs\|discussions\|notes\|events\|references` |
|
||||
| `-f, --for` | enum | — | Parent type: `issue\|mr` |
|
||||
|
||||
**DB tables:** Conditional aggregation on entity tables
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entity": "merge_requests",
|
||||
"count": 1234,
|
||||
"system_excluded": 5000,
|
||||
"breakdown": { "opened": 100, "closed": 50, "merged": 1084 }
|
||||
}
|
||||
}
|
||||
```
|
||||
452
docs/command-surface-analysis/02-intelligence-commands.md
Normal file
452
docs/command-surface-analysis/02-intelligence-commands.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Intelligence Commands
|
||||
|
||||
Reference for: `who`, `timeline`, `me`, `file-history`, `trace`, `related`, `drift`
|
||||
|
||||
---
|
||||
|
||||
## `who` (People Intelligence)
|
||||
|
||||
Five sub-modes, dispatched by argument shape.
|
||||
|
||||
| Mode | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| **expert** | `who <path>` or `who --path <path>` | Who knows about a code area? |
|
||||
| **workload** | `who @username` | What is this person working on? |
|
||||
| **reviews** | `who @username --reviews` | Review pattern analysis |
|
||||
| **active** | `who --active` | Unresolved discussions needing attention |
|
||||
| **overlap** | `who --overlap <path>` | Who else touches these files? |
|
||||
|
||||
### Shared Flags
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
| `-n, --limit` | int | varies | Max results (1-500) |
|
||||
| `--fields` | string | — | Preset: `minimal` |
|
||||
| `--since` | duration/date | — | Time window |
|
||||
| `--include-bots` | flag | — | Include bot users |
|
||||
| `--include-closed` | flag | — | Include closed issues/MRs |
|
||||
| `--all-history` | flag | — | Query all history |
|
||||
|
||||
### Expert-Only Flags
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--detail` | flag | — | Per-MR breakdown |
|
||||
| `--as-of` | date/duration | — | Score at point in time |
|
||||
| `--explain-score` | flag | — | Score breakdown |
|
||||
|
||||
### DB Tables by Mode
|
||||
|
||||
| Mode | Primary Tables |
|
||||
|---|---|
|
||||
| expert | `notes` (INDEXED BY idx_notes_diffnote_path_created), `merge_requests`, `mr_reviewers` |
|
||||
| workload | `issues`, `merge_requests`, `mr_reviewers` |
|
||||
| reviews | `merge_requests`, `discussions`, `notes` |
|
||||
| active | `discussions`, `notes`, `issues`, `merge_requests` |
|
||||
| overlap | `notes`, `mr_file_changes`, `merge_requests` |
|
||||
|
||||
### Robot Output (expert)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"mode": "expert",
|
||||
"input": { "target": "src/auth/", "path": "src/auth/" },
|
||||
"resolved_input": { "mode": "expert", "project_id": 1, "project_path": "group/repo" },
|
||||
"result": {
|
||||
"experts": [
|
||||
{
|
||||
"username": "jdoe", "score": 42.5,
|
||||
"detail": { "mr_ids_author": [99, 101], "mr_ids_reviewer": [88] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Robot Output (workload)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"mode": "workload",
|
||||
"result": {
|
||||
"assigned_issues": [{ "iid": 42, "title": "Fix auth", "state": "opened" }],
|
||||
"authored_mrs": [{ "iid": 99, "title": "Refactor auth", "state": "merged" }],
|
||||
"review_mrs": [{ "iid": 88, "title": "Add SSO", "state": "opened" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Robot Output (reviews)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"mode": "reviews",
|
||||
"result": {
|
||||
"categories": [
|
||||
{
|
||||
"category": "approval_rate",
|
||||
"reviewers": [{ "name": "jdoe", "count": 15, "percentage": 85.0 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Robot Output (active)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"mode": "active",
|
||||
"result": {
|
||||
"discussions": [
|
||||
{ "entity_type": "mr", "iid": 99, "title": "Refactor auth", "participants": ["jdoe", "reviewer"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Robot Output (overlap)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"mode": "overlap",
|
||||
"result": {
|
||||
"users": [{ "username": "jdoe", "touch_count": 15 }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Presets
|
||||
|
||||
| Mode | Fields |
|
||||
|---|---|
|
||||
| expert | `username`, `score` |
|
||||
| workload | `iid`, `title`, `state` |
|
||||
| reviews | `name`, `count`, `percentage` |
|
||||
| active | `entity_type`, `iid`, `title`, `participants` |
|
||||
| overlap | `username`, `touch_count` |
|
||||
|
||||
---
|
||||
|
||||
## `timeline`
|
||||
|
||||
Reconstruct chronological event history for a topic/entity with cross-reference expansion.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<QUERY>` | positional | required | Search text or entity ref (`issue:42`, `mr:99`) |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
| `--since` | duration/date | — | Filter events after |
|
||||
| `--depth` | int | 1 | Cross-ref expansion depth (0=none) |
|
||||
| `--no-mentions` | flag | — | Skip "mentioned" edges, keep "closes"/"related" |
|
||||
| `-n, --limit` | int | 100 | Max events |
|
||||
| `--fields` | string | — | Preset: `minimal` |
|
||||
| `--max-seeds` | int | 10 | Max seed entities from search |
|
||||
| `--max-entities` | int | 50 | Max expanded entities |
|
||||
| `--max-evidence` | int | 10 | Max evidence notes |
|
||||
|
||||
**Pipeline:** SEED -> HYDRATE -> EXPAND -> COLLECT -> RENDER
|
||||
|
||||
**DB tables:** `issues`, `merge_requests`, `discussions`, `notes`, `entity_references`, `resource_state_events`, `resource_label_events`, `resource_milestone_events`, `documents` (for search seeding)
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"query": "authentication", "event_count": 25,
|
||||
"seed_entities": [{ "type": "issue", "iid": 42, "project": "group/repo" }],
|
||||
"expanded_entities": [
|
||||
{
|
||||
"type": "mr", "iid": 99, "project": "group/repo", "depth": 1,
|
||||
"via": {
|
||||
"from": { "type": "issue", "iid": 42 },
|
||||
"reference_type": "closes"
|
||||
}
|
||||
}
|
||||
],
|
||||
"unresolved_references": [
|
||||
{
|
||||
"source": { "type": "issue", "iid": 42, "project": "group/repo" },
|
||||
"target_type": "mr", "target_iid": 200, "reference_type": "mentioned"
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"timestamp": "2026-01-15T10:30:00Z",
|
||||
"entity_type": "issue", "entity_iid": 42, "project": "group/repo",
|
||||
"event_type": "state_changed", "summary": "Reopened",
|
||||
"actor": "jdoe", "is_seed": true,
|
||||
"evidence_notes": [{ "author": "jdoe", "snippet": "..." }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"elapsed_ms": 150, "search_mode": "fts",
|
||||
"expansion_depth": 1, "include_mentions": true,
|
||||
"total_entities": 5, "total_events": 25,
|
||||
"evidence_notes_included": 8, "discussion_threads_included": 3,
|
||||
"unresolved_references": 1, "showing": 25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal preset:** `timestamp`, `type`, `entity_iid`, `detail`
|
||||
|
||||
---
|
||||
|
||||
## `me` (Personal Dashboard)
|
||||
|
||||
Personal work dashboard with issues, MRs, activity, and since-last-check inbox.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--issues` | flag | — | Open issues section only |
|
||||
| `--mrs` | flag | — | MRs section only |
|
||||
| `--activity` | flag | — | Activity feed only |
|
||||
| `--since` | duration/date | `30d` | Activity window |
|
||||
| `-p, --project` | string | — | Scope to one project |
|
||||
| `--all` | flag | — | All synced projects |
|
||||
| `--user` | string | — | Override configured username |
|
||||
| `--fields` | string | — | Preset: `minimal` |
|
||||
| `--reset-cursor` | flag | — | Clear since-last-check cursor |
|
||||
|
||||
**Sections (no flags = all):** Issues, MRs authored, MRs reviewing, Activity, Inbox
|
||||
|
||||
**DB tables:** `issues`, `merge_requests`, `resource_state_events`, `projects`, `issue_labels`, `mr_labels`
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"username": "jdoe",
|
||||
"summary": {
|
||||
"project_count": 3, "open_issue_count": 5,
|
||||
"authored_mr_count": 2, "reviewing_mr_count": 1,
|
||||
"needs_attention_count": 3
|
||||
},
|
||||
"since_last_check": {
|
||||
"cursor_iso": "2026-02-25T18:00:00Z",
|
||||
"total_event_count": 8,
|
||||
"groups": [
|
||||
{
|
||||
"entity_type": "issue", "entity_iid": 42,
|
||||
"entity_title": "Fix auth", "project": "group/repo",
|
||||
"events": [
|
||||
{ "timestamp_iso": "...", "event_type": "comment",
|
||||
"actor": "reviewer", "summary": "New comment" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"open_issues": [
|
||||
{
|
||||
"project": "group/repo", "iid": 42, "title": "Fix auth",
|
||||
"state": "opened", "attention_state": "needs_attention",
|
||||
"status_name": "In progress", "labels": ["auth"],
|
||||
"updated_at_iso": "..."
|
||||
}
|
||||
],
|
||||
"open_mrs_authored": [
|
||||
{
|
||||
"project": "group/repo", "iid": 99, "title": "Refactor auth",
|
||||
"state": "opened", "attention_state": "needs_attention",
|
||||
"draft": false, "labels": ["backend"], "updated_at_iso": "..."
|
||||
}
|
||||
],
|
||||
"reviewing_mrs": [],
|
||||
"activity": [
|
||||
{
|
||||
"timestamp_iso": "...", "event_type": "state_changed",
|
||||
"entity_type": "issue", "entity_iid": 42, "project": "group/repo",
|
||||
"actor": "jdoe", "is_own": true, "summary": "Closed"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal presets:** Items: `iid, title, attention_state, updated_at_iso` | Activity: `timestamp_iso, event_type, entity_iid, actor`
|
||||
|
||||
---
|
||||
|
||||
## `file-history`
|
||||
|
||||
Show which MRs touched a file, with linked discussions.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<PATH>` | positional | required | File path to trace |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
| `--discussions` | flag | — | Include DiffNote snippets |
|
||||
| `--no-follow-renames` | flag | — | Skip rename chain resolution |
|
||||
| `--merged` | flag | — | Only merged MRs |
|
||||
| `-n, --limit` | int | 50 | Max MRs |
|
||||
|
||||
**DB tables:** `mr_file_changes`, `merge_requests`, `notes` (DiffNotes), `projects`
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"path": "src/auth/middleware.rs",
|
||||
"rename_chain": [
|
||||
{ "previous_path": "src/auth.rs", "mr_iid": 55, "merged_at": "..." }
|
||||
],
|
||||
"merge_requests": [
|
||||
{
|
||||
"iid": 99, "title": "Refactor auth", "state": "merged",
|
||||
"author": "jdoe", "merged_at": "...", "change_type": "modified"
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"discussion_id": 123, "mr_iid": 99, "author": "reviewer",
|
||||
"body_snippet": "...", "path": "src/auth/middleware.rs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": { "elapsed_ms": 30, "total_mrs": 5, "renames_followed": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `trace`
|
||||
|
||||
File -> MR -> issue -> discussion chain to understand why code was introduced.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<PATH>` | positional | required | File path (future: `:line` suffix) |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
| `--discussions` | flag | — | Include DiffNote snippets |
|
||||
| `--no-follow-renames` | flag | — | Skip rename chain |
|
||||
| `-n, --limit` | int | 20 | Max chains |
|
||||
|
||||
**DB tables:** `mr_file_changes`, `merge_requests`, `issues`, `discussions`, `notes`, `entity_references`
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"path": "src/auth/middleware.rs",
|
||||
"resolved_paths": ["src/auth/middleware.rs", "src/auth.rs"],
|
||||
"trace_chains": [
|
||||
{
|
||||
"mr_iid": 99, "mr_title": "Refactor auth", "mr_state": "merged",
|
||||
"mr_author": "jdoe", "change_type": "modified",
|
||||
"merged_at_iso": "...", "web_url": "...",
|
||||
"issues": [42],
|
||||
"discussions": [
|
||||
{
|
||||
"discussion_id": 123, "author_username": "reviewer",
|
||||
"body_snippet": "...", "path": "src/auth/middleware.rs"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": { "tier": "api_only", "total_chains": 3, "renames_followed": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `related`
|
||||
|
||||
Find semantically related entities via vector search.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<QUERY_OR_TYPE>` | positional | required | Entity type (`issues`, `mrs`) or free text |
|
||||
| `[IID]` | positional | — | Entity IID (required with entity type) |
|
||||
| `-n, --limit` | int | 10 | Max results |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
|
||||
**Two modes:**
|
||||
- **Entity mode:** `related issues 42` — find entities similar to issue #42
|
||||
- **Query mode:** `related "auth flow"` — find entities matching free text
|
||||
|
||||
**DB tables:** `documents`, `embeddings` (vec0), `projects`
|
||||
|
||||
**Requires:** Ollama running (for query mode embedding)
|
||||
|
||||
### Robot Output (entity mode)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"query_entity_type": "issue",
|
||||
"query_entity_iid": 42,
|
||||
"query_entity_title": "Fix SSO authentication",
|
||||
"similar_entities": [
|
||||
{
|
||||
"entity_type": "mr", "entity_iid": 99,
|
||||
"entity_title": "Refactor auth module",
|
||||
"project_path": "group/repo", "state": "merged",
|
||||
"similarity_score": 0.87,
|
||||
"shared_labels": ["auth"], "shared_authors": ["jdoe"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `drift`
|
||||
|
||||
Detect discussion divergence from original intent.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `<ENTITY_TYPE>` | positional | required | Currently only `issues` |
|
||||
| `<IID>` | positional | required | Entity IID |
|
||||
| `--threshold` | f32 | 0.4 | Similarity threshold (0.0-1.0) |
|
||||
| `-p, --project` | string | — | Scope to project |
|
||||
|
||||
**DB tables:** `issues`, `discussions`, `notes`, `embeddings`
|
||||
|
||||
**Requires:** Ollama running
|
||||
|
||||
### Robot Output
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entity_type": "issue", "entity_iid": 42,
|
||||
"total_notes": 15,
|
||||
"detected_drift": true,
|
||||
"drift_point": {
|
||||
"note_index": 8, "similarity": 0.32,
|
||||
"author": "someone", "created_at": "..."
|
||||
},
|
||||
"similarity_curve": [
|
||||
{ "note_index": 0, "similarity": 0.95, "author": "jdoe", "created_at": "..." },
|
||||
{ "note_index": 1, "similarity": 0.88, "author": "reviewer", "created_at": "..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
210
docs/command-surface-analysis/03-pipeline-and-infra.md
Normal file
210
docs/command-surface-analysis/03-pipeline-and-infra.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Pipeline & Infrastructure Commands
|
||||
|
||||
Reference for: `sync`, `ingest`, `generate-docs`, `embed`, `health`, `auth`, `doctor`, `status`, `stats`, `init`, `token`, `cron`, `migrate`, `version`, `completions`, `robot-docs`
|
||||
|
||||
---
|
||||
|
||||
## Data Pipeline
|
||||
|
||||
### `sync` (Full Pipeline)
|
||||
|
||||
Complete sync: ingest -> generate-docs -> embed.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--full` | flag | — | Full re-sync (reset cursors) |
|
||||
| `-f, --force` | flag | — | Override stale lock |
|
||||
| `--no-embed` | flag | — | Skip embedding |
|
||||
| `--no-docs` | flag | — | Skip doc generation |
|
||||
| `--no-events` | flag | — | Skip resource events |
|
||||
| `--no-file-changes` | flag | — | Skip MR file changes |
|
||||
| `--no-status` | flag | — | Skip work-item status enrichment |
|
||||
| `--dry-run` | flag | — | Preview without changes |
|
||||
| `-t, --timings` | flag | — | Show timing breakdown |
|
||||
| `--lock` | flag | — | Acquire file lock |
|
||||
| `--issue` | int[] | — | Surgically sync specific issues (repeatable) |
|
||||
| `--mr` | int[] | — | Surgically sync specific MRs (repeatable) |
|
||||
| `-p, --project` | string | — | Required with `--issue`/`--mr` |
|
||||
| `--preflight-only` | flag | — | Validate without DB writes |
|
||||
|
||||
**Stages:** GitLab REST ingest -> GraphQL status enrichment -> Document generation -> Ollama embedding
|
||||
|
||||
**Surgical sync:** `lore sync --issue 42 --mr 99 -p group/repo` fetches only specific entities.
|
||||
|
||||
### `ingest`
|
||||
|
||||
Fetch data from GitLab API only (no docs, no embeddings).
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `[ENTITY]` | positional | — | `issues` or `mrs` (omit for all) |
|
||||
| `-p, --project` | string | — | Single project |
|
||||
| `-f, --force` | flag | — | Override stale lock |
|
||||
| `--full` | flag | — | Full re-sync |
|
||||
| `--dry-run` | flag | — | Preview |
|
||||
|
||||
**Fetches from GitLab:**
|
||||
- Issues + discussions + notes
|
||||
- MRs + discussions + notes
|
||||
- Resource events (state, label, milestone)
|
||||
- MR file changes (for DiffNote tracking)
|
||||
- Work-item statuses (via GraphQL)
|
||||
|
||||
### `generate-docs`
|
||||
|
||||
Create searchable documents from ingested data.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--full` | flag | — | Full rebuild |
|
||||
| `-p, --project` | string | — | Single project rebuild |
|
||||
|
||||
**Writes:** `documents`, `document_labels`, `document_paths`
|
||||
|
||||
### `embed`
|
||||
|
||||
Generate vector embeddings via Ollama.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--full` | flag | — | Re-embed all |
|
||||
| `--retry-failed` | flag | — | Retry failed embeddings |
|
||||
|
||||
**Requires:** Ollama running with `nomic-embed-text`
|
||||
**Writes:** `embeddings`, `embedding_metadata`
|
||||
|
||||
---
|
||||
|
||||
## Diagnostics
|
||||
|
||||
### `health`
|
||||
|
||||
Quick pre-flight check (~50ms). Exit 0 = healthy, exit 19 = unhealthy.
|
||||
|
||||
**Checks:** config found, DB found, schema version current.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"healthy": true,
|
||||
"config_found": true, "db_found": true,
|
||||
"schema_current": true, "schema_version": 28
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `auth`
|
||||
|
||||
Verify GitLab authentication.
|
||||
|
||||
**Checks:** token set, GitLab reachable, user identity.
|
||||
|
||||
### `doctor`
|
||||
|
||||
Comprehensive environment check.
|
||||
|
||||
**Checks:** config validity, token, GitLab connectivity, DB health, migration status, Ollama availability + model status.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"config": { "valid": true, "path": "~/.config/lore/config.json" },
|
||||
"token": { "set": true, "gitlab": { "reachable": true, "user": "jdoe" } },
|
||||
"database": { "exists": true, "version": 28, "tables": 25 },
|
||||
"ollama": { "available": true, "model_ready": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `status` (alias: `st`)
|
||||
|
||||
Show sync state per project.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"projects": [
|
||||
{
|
||||
"project_path": "group/repo",
|
||||
"last_synced_at": "2026-02-26T10:00:00Z",
|
||||
"document_count": 5000, "discussion_count": 2000, "notes_count": 15000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `stats` (alias: `stat`)
|
||||
|
||||
Document and index statistics with optional integrity checks.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--check` | flag | — | Run integrity checks |
|
||||
| `--repair` | flag | — | Fix issues (implies `--check`) |
|
||||
| `--dry-run` | flag | — | Preview repairs |
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"documents": { "total": 61652, "issues": 5000, "mrs": 2000, "notes": 50000 },
|
||||
"embeddings": { "total": 80000, "synced": 79500, "pending": 500, "failed": 0 },
|
||||
"fts": { "total_docs": 61652 },
|
||||
"queues": { "pending": 0, "in_progress": 0, "failed": 0, "max_attempts": 0 },
|
||||
"integrity": {
|
||||
"ok": true, "fts_doc_mismatch": 0, "orphan_embeddings": 0,
|
||||
"stale_metadata": 0, "orphan_state_events": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### `init`
|
||||
|
||||
Initialize configuration and database.
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `-f, --force` | flag | — | Skip overwrite confirmation |
|
||||
| `--non-interactive` | flag | — | Fail if prompts needed |
|
||||
| `--gitlab-url` | string | — | GitLab base URL (required in robot mode) |
|
||||
| `--token-env-var` | string | — | Env var holding token (required in robot mode) |
|
||||
| `--projects` | string | — | Comma-separated project paths (required in robot mode) |
|
||||
| `--default-project` | string | — | Default project path |
|
||||
|
||||
### `token`
|
||||
|
||||
| Subcommand | Flags | Purpose |
|
||||
|---|---|---|
|
||||
| `token set` | `--token <TOKEN>` | Store token (reads stdin if omitted) |
|
||||
| `token show` | `--unmask` | Display token (masked by default) |
|
||||
|
||||
### `cron`
|
||||
|
||||
| Subcommand | Flags | Purpose |
|
||||
|---|---|---|
|
||||
| `cron install` | `--interval <MINUTES>` (default: 8) | Schedule auto-sync |
|
||||
| `cron uninstall` | — | Remove cron job |
|
||||
| `cron status` | — | Check installation |
|
||||
|
||||
### `migrate`
|
||||
|
||||
Run pending database migrations. No flags.
|
||||
|
||||
---
|
||||
|
||||
## Meta
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `version` | Show version string |
|
||||
| `completions <shell>` | Generate shell completions (bash/zsh/fish/powershell) |
|
||||
| `robot-docs` | Machine-readable command manifest (`--brief` for ~60% smaller) |
|
||||
179
docs/command-surface-analysis/04-data-flow.md
Normal file
179
docs/command-surface-analysis/04-data-flow.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Data Flow & Command Network
|
||||
|
||||
How commands interconnect through shared data sources and output-to-input dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Command Network Graph
|
||||
|
||||
Arrows mean "output of A feeds as input to B":
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ search │─────────────────────────────┐
|
||||
└────┬────┘ │
|
||||
│ iid │ topic
|
||||
┌────▼────┐ ┌────▼─────┐
|
||||
┌─────│ issues │◄───────────────────────│ timeline │
|
||||
│ │ mrs │ (detail) └──────────┘
|
||||
│ └────┬────┘ ▲
|
||||
│ │ iid │ entity ref
|
||||
│ ┌────▼────┐ ┌──────────────┐ │
|
||||
│ │ related │ │ file-history │───────┘
|
||||
│ │ drift │ └──────┬───────┘
|
||||
│ └─────────┘ │ MR iids
|
||||
│ ┌────▼────┐
|
||||
│ │ trace │──── issues (linked)
|
||||
│ └────┬────┘
|
||||
│ │ paths
|
||||
│ ┌────▼────┐
|
||||
│ │ who │
|
||||
│ │ (expert)│
|
||||
│ └─────────┘
|
||||
│
|
||||
file paths ┌─────────┐
|
||||
│ │ me │──── issues, mrs (dashboard)
|
||||
▼ └─────────┘
|
||||
┌──────────┐ ▲
|
||||
│ notes │ │ (~same data)
|
||||
└──────────┘ ┌────┴──────┐
|
||||
│who workload│
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### Feed Chains (output of A -> input of B)
|
||||
|
||||
| From | To | What Flows |
|
||||
|---|---|---|
|
||||
| `search` | `issues`, `mrs` | IIDs from search results -> detail lookup |
|
||||
| `search` | `timeline` | Topic/query -> chronological history |
|
||||
| `search` | `related` | Entity IID -> semantic similarity |
|
||||
| `me` | `issues`, `mrs` | IIDs from dashboard -> detail lookup |
|
||||
| `trace` | `issues` | Linked issue IIDs -> detail lookup |
|
||||
| `trace` | `who` | File paths -> expert lookup |
|
||||
| `file-history` | `mrs` | MR IIDs -> detail lookup |
|
||||
| `file-history` | `timeline` | Entity refs -> chronological events |
|
||||
| `timeline` | `issues`, `mrs` | Referenced IIDs -> detail lookup |
|
||||
| `who expert` | `who reviews` | Username -> review patterns |
|
||||
| `who expert` | `mrs` | MR IIDs from expert detail -> MR detail |
|
||||
|
||||
---
|
||||
|
||||
## 2. Shared Data Source Map
|
||||
|
||||
Which DB tables power which commands. Higher overlap = stronger consolidation signal.
|
||||
|
||||
### Primary Entity Tables
|
||||
|
||||
| Table | Read By |
|
||||
|---|---|
|
||||
| `issues` | issues, me, who-workload, search, timeline, trace, count, stats |
|
||||
| `merge_requests` | mrs, me, who-workload, search, timeline, trace, file-history, count, stats |
|
||||
| `notes` | notes, issues-detail, mrs-detail, who-expert, who-active, search, timeline, trace, file-history |
|
||||
| `discussions` | notes, issues-detail, mrs-detail, who-active, who-reviews, timeline, trace |
|
||||
|
||||
### Relationship Tables
|
||||
|
||||
| Table | Read By |
|
||||
|---|---|
|
||||
| `entity_references` | trace, timeline |
|
||||
| `mr_file_changes` | trace, file-history, who-overlap |
|
||||
| `issue_labels` | issues, me |
|
||||
| `mr_labels` | mrs, me |
|
||||
| `issue_assignees` | issues, me |
|
||||
| `mr_reviewers` | mrs, who-expert, who-workload |
|
||||
|
||||
### Event Tables
|
||||
|
||||
| Table | Read By |
|
||||
|---|---|
|
||||
| `resource_state_events` | timeline, me-activity |
|
||||
| `resource_label_events` | timeline |
|
||||
| `resource_milestone_events` | timeline |
|
||||
|
||||
### Document/Search Tables
|
||||
|
||||
| Table | Read By |
|
||||
|---|---|
|
||||
| `documents` + `documents_fts` | search, stats |
|
||||
| `embeddings` | search, related, drift |
|
||||
| `document_labels` | search |
|
||||
| `document_paths` | search |
|
||||
|
||||
### Infrastructure Tables
|
||||
|
||||
| Table | Read By |
|
||||
|---|---|
|
||||
| `sync_cursors` | status |
|
||||
| `dirty_sources` | stats |
|
||||
| `embedding_metadata` | stats, embed |
|
||||
|
||||
---
|
||||
|
||||
## 3. Shared-Data Clusters
|
||||
|
||||
Commands that read from the same primary tables form natural clusters:
|
||||
|
||||
### Cluster A: Issue/MR Entities
|
||||
|
||||
`issues`, `mrs`, `me`, `who workload`, `count`
|
||||
|
||||
All read `issues` + `merge_requests` with similar filter patterns (state, author, labels, project). These commands share the same underlying WHERE-clause builder logic.
|
||||
|
||||
### Cluster B: Notes/Discussions
|
||||
|
||||
`notes`, `issues detail`, `mrs detail`, `who expert`, `who active`, `timeline`
|
||||
|
||||
All traverse the `discussions` -> `notes` join path. The `notes` command does it with independent filters; the others embed notes within parent context.
|
||||
|
||||
### Cluster C: File Genealogy
|
||||
|
||||
`trace`, `file-history`, `who overlap`
|
||||
|
||||
All use `mr_file_changes` with rename chain BFS (forward: old_path -> new_path, backward: new_path -> old_path). Shared `resolve_rename_chain()` function.
|
||||
|
||||
### Cluster D: Semantic/Vector
|
||||
|
||||
`search`, `related`, `drift`
|
||||
|
||||
All use `documents` + `embeddings` via Ollama. `search` adds FTS component; `related` is pure vector; `drift` uses vector for divergence scoring.
|
||||
|
||||
### Cluster E: Diagnostics
|
||||
|
||||
`health`, `auth`, `doctor`, `status`, `stats`
|
||||
|
||||
All check system state. `health` < `doctor` (strict subset). `status` checks sync cursors. `stats` checks document/index health. `auth` checks token/connectivity.
|
||||
|
||||
---
|
||||
|
||||
## 4. Query Pattern Sharing
|
||||
|
||||
### Dynamic Filter Builder (used by issues, mrs, notes)
|
||||
|
||||
All three list commands use the same pattern: build a WHERE clause dynamically from filter flags with parameterized tokens. Labels use EXISTS subquery against junction table.
|
||||
|
||||
### Rename Chain BFS (used by trace, file-history, who overlap)
|
||||
|
||||
Forward query:
|
||||
```sql
|
||||
SELECT DISTINCT new_path FROM mr_file_changes
|
||||
WHERE project_id = ?1 AND old_path = ?2 AND change_type = 'renamed'
|
||||
```
|
||||
|
||||
Backward query:
|
||||
```sql
|
||||
SELECT DISTINCT old_path FROM mr_file_changes
|
||||
WHERE project_id = ?1 AND new_path = ?2 AND change_type = 'renamed'
|
||||
```
|
||||
|
||||
Cycle detection via `HashSet` of visited paths, `MAX_RENAME_HOPS = 10`.
|
||||
|
||||
### Hybrid Search (used by search, timeline seeding)
|
||||
|
||||
RRF ranking: `score = (60 / fts_rank) + (60 / vector_rank)`
|
||||
|
||||
FTS5 queries go through `to_fts_query()` which sanitizes input and builds MATCH expressions. Vector search calls Ollama to embed the query, then does cosine similarity against `embeddings` vec0 table.
|
||||
|
||||
### Project Resolution (used by most commands)
|
||||
|
||||
`resolve_project(conn, project_filter)` does fuzzy matching on `path_with_namespace` — suffix and substring matching. Returns `(project_id, path_with_namespace)`.
|
||||
170
docs/command-surface-analysis/05-overlap-analysis.md
Normal file
170
docs/command-surface-analysis/05-overlap-analysis.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Overlap Analysis
|
||||
|
||||
Quantified functional duplication between commands.
|
||||
|
||||
---
|
||||
|
||||
## 1. High Overlap (>70%)
|
||||
|
||||
### `who workload` vs `me` — 85% overlap
|
||||
|
||||
| Dimension | `who @user` (workload) | `me --user @user` |
|
||||
|---|---|---|
|
||||
| Assigned issues | Yes | Yes |
|
||||
| Authored MRs | Yes | Yes |
|
||||
| Reviewing MRs | Yes | Yes |
|
||||
| Attention state | No | **Yes** |
|
||||
| Activity feed | No | **Yes** |
|
||||
| Since-last-check inbox | No | **Yes** |
|
||||
| Cross-project | Yes | **Yes** |
|
||||
|
||||
**Verdict:** `who workload` is a strict subset of `me`. The only reason to use `who workload` is if you DON'T want attention_state/activity/inbox — but `me --issues --mrs --fields minimal` achieves the same thing.
|
||||
|
||||
### `health` vs `doctor` — 90% overlap
|
||||
|
||||
| Check | `health` | `doctor` |
|
||||
|---|---|---|
|
||||
| Config found | Yes | Yes |
|
||||
| DB exists | Yes | Yes |
|
||||
| Schema current | Yes | Yes |
|
||||
| Token valid | No | **Yes** |
|
||||
| GitLab reachable | No | **Yes** |
|
||||
| Ollama available | No | **Yes** |
|
||||
|
||||
**Verdict:** `health` is a strict subset of `doctor`. However, `health` has unique value as a ~50ms pre-flight with clean exit 0/19 semantics for scripting.
|
||||
|
||||
### `file-history` vs `trace` — 75% overlap
|
||||
|
||||
| Feature | `file-history` | `trace` |
|
||||
|---|---|---|
|
||||
| Find MRs for file | Yes | Yes |
|
||||
| Rename chain BFS | Yes | Yes |
|
||||
| DiffNote discussions | `--discussions` | `--discussions` |
|
||||
| Follow to linked issues | No | **Yes** |
|
||||
| `--merged` filter | **Yes** | No |
|
||||
|
||||
**Verdict:** `trace` is a superset of `file-history` minus the `--merged` filter. Both use the same `resolve_rename_chain()` function and query `mr_file_changes`.
|
||||
|
||||
### `related` query-mode vs `search --mode semantic` — 80% overlap
|
||||
|
||||
| Feature | `related "text"` | `search "text" --mode semantic` |
|
||||
|---|---|---|
|
||||
| Vector similarity | Yes | Yes |
|
||||
| FTS component | No | No (semantic mode skips FTS) |
|
||||
| Filters (labels, author, since) | No | **Yes** |
|
||||
| Explain ranking | No | **Yes** |
|
||||
| Field selection | No | **Yes** |
|
||||
| Requires Ollama | Yes | Yes |
|
||||
|
||||
**Verdict:** `related "text"` is `search --mode semantic` without any filter capabilities. The entity-seeded mode (`related issues 42`) is NOT duplicated — it seeds from an existing entity's embedding.
|
||||
|
||||
---
|
||||
|
||||
## 2. Medium Overlap (40-70%)
|
||||
|
||||
### `who expert` vs `who overlap` — 50%
|
||||
|
||||
Both answer "who works on this file" but with different scoring:
|
||||
|
||||
| Aspect | `who expert` | `who overlap` |
|
||||
|---|---|---|
|
||||
| Scoring | Half-life decay, signal types (diffnote_author, reviewer, etc.) | Raw touch count |
|
||||
| Output | Ranked experts with scores | Users with touch counts |
|
||||
| Use case | "Who should review this?" | "Who else touches this?" |
|
||||
|
||||
**Verdict:** Overlap is a simplified version of expert. Expert could include touch_count as a field.
|
||||
|
||||
### `timeline` vs `trace` — 45%
|
||||
|
||||
Both follow `entity_references` to discover connected entities, but from different entry points:
|
||||
|
||||
| Aspect | `timeline` | `trace` |
|
||||
|---|---|---|
|
||||
| Entry point | Entity (issue/MR) or search query | File path |
|
||||
| Direction | Entity -> cross-refs -> events | File -> MRs -> issues -> discussions |
|
||||
| Output | Chronological events | Causal chains (why code changed) |
|
||||
| Expansion | Depth-controlled cross-ref following | MR -> issue via entity_references |
|
||||
|
||||
**Verdict:** Complementary, not duplicative. Different questions, shared plumbing.
|
||||
|
||||
### `auth` vs `doctor` — 100% of auth
|
||||
|
||||
`auth` checks: token set + GitLab reachable + user identity.
|
||||
`doctor` checks: all of the above + DB + schema + Ollama.
|
||||
|
||||
**Verdict:** `auth` is completely contained within `doctor`.
|
||||
|
||||
### `count` vs `stats` — 40%
|
||||
|
||||
Both answer "how much data?":
|
||||
|
||||
| Aspect | `count` | `stats` |
|
||||
|---|---|---|
|
||||
| Layer | Entity (issues, MRs, notes) | Document index |
|
||||
| State breakdown | Yes (opened/closed/merged) | No |
|
||||
| Integrity checks | No | Yes |
|
||||
| Queue status | No | Yes |
|
||||
|
||||
**Verdict:** Different layers. Could be unified under `stats --entities`.
|
||||
|
||||
### `notes` vs `issues/mrs detail` — 50%
|
||||
|
||||
Both return note content:
|
||||
|
||||
| Aspect | `notes` command | Detail view discussions |
|
||||
|---|---|---|
|
||||
| Independent filtering | **Yes** (author, path, resolution, contains, type) | No |
|
||||
| Parent context | Minimal (parent_iid, parent_title) | **Full** (complete entity + all discussions) |
|
||||
| Cross-entity queries | **Yes** (all notes matching criteria) | No (one entity only) |
|
||||
|
||||
**Verdict:** `notes` is for filtered queries across entities. Detail views are for complete context on one entity. Different use cases.
|
||||
|
||||
---
|
||||
|
||||
## 3. No Significant Overlap
|
||||
|
||||
| Command | Why It's Unique |
|
||||
|---|---|
|
||||
| `drift` | Only command doing semantic divergence detection |
|
||||
| `timeline` | Only command doing multi-entity chronological reconstruction with expansion |
|
||||
| `search` (hybrid) | Only command combining FTS + vector with RRF ranking |
|
||||
| `me` (inbox) | Only command with cursor-based since-last-check tracking |
|
||||
| `who expert` | Only command with half-life decay scoring by signal type |
|
||||
| `who reviews` | Only command analyzing review patterns (approval rate, latency) |
|
||||
| `who active` | Only command surfacing unresolved discussions needing attention |
|
||||
|
||||
---
|
||||
|
||||
## 4. Overlap Adjacency Matrix
|
||||
|
||||
Rows/columns are commands. Values are estimated functional overlap percentage.
|
||||
|
||||
```
|
||||
issues mrs notes search who-e who-w who-r who-a who-o timeline me fh trace related drift count status stats health doctor
|
||||
issues - 30 50 20 5 40 0 5 0 15 40 0 10 10 0 20 0 10 0 0
|
||||
mrs 30 - 50 20 5 40 0 5 0 15 40 5 10 10 0 20 0 10 0 0
|
||||
notes 50 50 - 15 15 0 5 10 0 10 0 5 5 0 0 0 0 0 0 0
|
||||
search 20 20 15 - 0 0 0 0 0 15 0 0 0 80 0 0 0 5 0 0
|
||||
who-expert 5 5 15 0 - 0 10 0 50 0 0 10 10 0 0 0 0 0 0 0
|
||||
who-workload 40 40 0 0 0 - 0 0 0 0 85 0 0 0 0 0 0 0 0 0
|
||||
who-reviews 0 0 5 0 10 0 - 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
who-active 5 5 10 0 0 0 0 - 0 5 0 0 0 0 0 0 0 0 0 0
|
||||
who-overlap 0 0 0 0 50 0 0 0 - 0 0 10 5 0 0 0 0 0 0 0
|
||||
timeline 15 15 10 15 0 0 0 5 0 - 5 5 45 0 0 0 0 0 0 0
|
||||
me 40 40 0 0 0 85 0 0 0 5 - 0 0 0 0 0 5 0 5 5
|
||||
file-history 0 5 5 0 10 0 0 0 10 5 0 - 75 0 0 0 0 0 0 0
|
||||
trace 10 10 5 0 10 0 0 0 5 45 0 75 - 0 0 0 0 0 0 0
|
||||
related 10 10 0 80 0 0 0 0 0 0 0 0 0 - 0 0 0 0 0 0
|
||||
drift 0 0 0 0 0 0 0 0 0 0 0 0 0 0 - 0 0 0 0 0
|
||||
count 20 20 0 0 0 0 0 0 0 0 0 0 0 0 0 - 0 40 0 0
|
||||
status 0 0 0 0 0 0 0 0 0 0 5 0 0 0 0 0 - 20 30 40
|
||||
stats 10 10 0 5 0 0 0 0 0 0 0 0 0 0 0 40 20 - 0 15
|
||||
health 0 0 0 0 0 0 0 0 0 0 5 0 0 0 0 0 30 0 - 90
|
||||
doctor 0 0 0 0 0 0 0 0 0 0 5 0 0 0 0 0 40 15 90 -
|
||||
```
|
||||
|
||||
**Highest overlap pairs (>= 75%):**
|
||||
1. `health` / `doctor` — 90%
|
||||
2. `who workload` / `me` — 85%
|
||||
3. `related` query-mode / `search semantic` — 80%
|
||||
4. `file-history` / `trace` — 75%
|
||||
216
docs/command-surface-analysis/06-agent-workflows.md
Normal file
216
docs/command-surface-analysis/06-agent-workflows.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Agent Workflow Analysis
|
||||
|
||||
Common agent workflows, round-trip costs, and token profiles.
|
||||
|
||||
---
|
||||
|
||||
## 1. Common Workflows
|
||||
|
||||
### Flow 1: "What should I work on?" — 4 round trips
|
||||
|
||||
```
|
||||
me → dashboard overview (which items need attention?)
|
||||
issues <iid> -p proj → detail on picked issue (full context + discussions)
|
||||
trace src/relevant/file.rs → understand code context (why was it written?)
|
||||
who src/relevant/file.rs → find domain experts (who can help?)
|
||||
```
|
||||
|
||||
**Total tokens (minimal):** ~800 + ~2000 + ~1000 + ~400 = ~4200
|
||||
**Total tokens (full):** ~3000 + ~6000 + ~1500 + ~800 = ~11300
|
||||
**Latency:** 4 serial round trips
|
||||
|
||||
### Flow 2: "What happened with this feature?" — 3 round trips
|
||||
|
||||
```
|
||||
search "feature name" → find relevant entities
|
||||
timeline "feature name" → reconstruct chronological history
|
||||
related issues 42 → discover connected work
|
||||
```
|
||||
|
||||
**Total tokens (minimal):** ~600 + ~1500 + ~400 = ~2500
|
||||
**Total tokens (full):** ~2000 + ~5000 + ~1000 = ~8000
|
||||
**Latency:** 3 serial round trips
|
||||
|
||||
### Flow 3: "Why was this code changed?" — 3 round trips
|
||||
|
||||
```
|
||||
trace src/file.rs → file -> MR -> issue chain
|
||||
issues <iid> -p proj → full issue detail
|
||||
timeline "issue:42" → full history with cross-refs
|
||||
```
|
||||
|
||||
**Total tokens (minimal):** ~800 + ~2000 + ~1500 = ~4300
|
||||
**Total tokens (full):** ~1500 + ~6000 + ~5000 = ~12500
|
||||
**Latency:** 3 serial round trips
|
||||
|
||||
### Flow 4: "Is the system healthy?" — 2-4 round trips
|
||||
|
||||
```
|
||||
health → quick pre-flight (pass/fail)
|
||||
doctor → detailed diagnostics (if health fails)
|
||||
status → sync state per project
|
||||
stats → document/index health
|
||||
```
|
||||
|
||||
**Total tokens:** ~100 + ~300 + ~200 + ~400 = ~1000
|
||||
**Latency:** 2-4 serial round trips (often 1 if health passes)
|
||||
|
||||
### Flow 5: "Who can review this?" — 2-3 round trips
|
||||
|
||||
```
|
||||
who src/auth/ → find file experts
|
||||
who @jdoe --reviews → check reviewer's patterns
|
||||
```
|
||||
|
||||
**Total tokens (minimal):** ~300 + ~300 = ~600
|
||||
**Latency:** 2 serial round trips
|
||||
|
||||
### Flow 6: "Find and understand an issue" — 4 round trips
|
||||
|
||||
```
|
||||
search "query" → discover entities (get IIDs)
|
||||
issues <iid> → full detail with discussions
|
||||
timeline "issue:42" → chronological context
|
||||
related issues 42 → connected entities
|
||||
```
|
||||
|
||||
**Total tokens (minimal):** ~600 + ~2000 + ~1500 + ~400 = ~4500
|
||||
**Total tokens (full):** ~2000 + ~6000 + ~5000 + ~1000 = ~14000
|
||||
**Latency:** 4 serial round trips
|
||||
|
||||
---
|
||||
|
||||
## 2. Token Cost Profiles
|
||||
|
||||
Measured typical response sizes in robot mode with default settings:
|
||||
|
||||
| Command | Typical Tokens (full) | With `--fields minimal` | Dominant Cost Driver |
|
||||
|---|---|---|---|
|
||||
| `me` (all sections) | 2000-5000 | 500-1500 | Open items count |
|
||||
| `issues` (list, n=50) | 1500-3000 | 400-800 | Labels arrays |
|
||||
| `issues <iid>` (detail) | 1000-8000 | N/A (no minimal for detail) | Discussion depth |
|
||||
| `mrs <iid>` (detail) | 1000-8000 | N/A | Discussion depth, DiffNote positions |
|
||||
| `timeline` (limit=100) | 2000-6000 | 800-1500 | Event count + evidence |
|
||||
| `search` (n=20) | 1000-3000 | 300-600 | Snippet length |
|
||||
| `who expert` | 300-800 | 150-300 | Expert count |
|
||||
| `who workload` | 500-1500 | 200-500 | Open items count |
|
||||
| `trace` | 500-2000 | 300-800 | Chain depth |
|
||||
| `file-history` | 300-1500 | 200-500 | MR count |
|
||||
| `related` | 300-1000 | 200-400 | Result count |
|
||||
| `drift` | 200-800 | N/A | Similarity curve length |
|
||||
| `notes` (n=50) | 1500-5000 | 500-1000 | Body length |
|
||||
| `count` | ~100 | N/A | Fixed structure |
|
||||
| `stats` | ~500 | N/A | Fixed structure |
|
||||
| `health` | ~100 | N/A | Fixed structure |
|
||||
| `doctor` | ~300 | N/A | Fixed structure |
|
||||
| `status` | ~200 | N/A | Project count |
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Detail commands are expensive.** `issues <iid>` and `mrs <iid>` can hit 8000 tokens due to discussions. This is the content agents actually need, but most of it is discussion body text.
|
||||
|
||||
2. **`me` is the most-called command** and ranges 2000-5000 tokens. Agents often just need "do I have work?" which is ~100 tokens (summary counts only).
|
||||
|
||||
3. **Lists with labels are wasteful.** Every issue/MR in a list carries its full label array. With 50 items x 5 labels each, that's 250 strings of overhead.
|
||||
|
||||
4. **`--fields minimal` helps a lot** — 50-70% reduction on list commands. But it's not available on detail views.
|
||||
|
||||
5. **Timeline scales linearly** with event count and evidence notes. The `--max-evidence` flag helps cap the expensive part.
|
||||
|
||||
---
|
||||
|
||||
## 3. Round-Trip Inefficiency Patterns
|
||||
|
||||
### Pattern A: Discovery -> Detail (N+1)
|
||||
|
||||
Agent searches, gets 5 results, then needs detail on each:
|
||||
|
||||
```
|
||||
search "auth bug" → 5 results
|
||||
issues 42 -p proj → detail
|
||||
issues 55 -p proj → detail
|
||||
issues 71 -p proj → detail
|
||||
issues 88 -p proj → detail
|
||||
issues 95 -p proj → detail
|
||||
```
|
||||
|
||||
**6 round trips** for what should be 2 (search + batch detail).
|
||||
|
||||
### Pattern B: Detail -> Context Gathering
|
||||
|
||||
Agent gets issue detail, then needs timeline + related + trace:
|
||||
|
||||
```
|
||||
issues 42 -p proj → detail
|
||||
timeline "issue:42" -p proj → events
|
||||
related issues 42 -p proj → similar
|
||||
trace src/file.rs -p proj → code provenance
|
||||
```
|
||||
|
||||
**4 round trips** for what should be 1 (detail with embedded context).
|
||||
|
||||
### Pattern C: Health Check Cascade
|
||||
|
||||
Agent checks health, discovers issue, drills down:
|
||||
|
||||
```
|
||||
health → unhealthy (exit 19)
|
||||
doctor → token OK, Ollama missing
|
||||
stats --check → 5 orphan embeddings
|
||||
stats --repair → fixed
|
||||
```
|
||||
|
||||
**4 round trips** but only 2 are actually needed (doctor covers health).
|
||||
|
||||
### Pattern D: Dashboard -> Action
|
||||
|
||||
Agent checks dashboard, picks item, needs full context:
|
||||
|
||||
```
|
||||
me → 5 open issues, 2 MRs
|
||||
issues 42 -p proj → picked issue detail
|
||||
who src/auth/ -p proj → expert for help
|
||||
timeline "issue:42" -p proj → history
|
||||
```
|
||||
|
||||
**4 round trips.** With `--include`, could be 2 (me with inline detail + who).
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimized Workflow Vision
|
||||
|
||||
What the same workflows look like with proposed optimizations:
|
||||
|
||||
### Flow 1 Optimized: "What should I work on?" — 2 round trips
|
||||
|
||||
```
|
||||
me --depth titles → 400 tokens: counts + item titles with attention_state
|
||||
issues 42 --include timeline,trace → 1 call: detail + events + code provenance
|
||||
```
|
||||
|
||||
### Flow 2 Optimized: "What happened with this feature?" — 1-2 round trips
|
||||
|
||||
```
|
||||
search "feature" -n 5 → find entities
|
||||
issues 42 --include timeline,related → everything in one call
|
||||
```
|
||||
|
||||
### Flow 3 Optimized: "Why was this code changed?" — 1 round trip
|
||||
|
||||
```
|
||||
trace src/file.rs --include experts,timeline → full chain + experts + events
|
||||
```
|
||||
|
||||
### Flow 4 Optimized: "Is the system healthy?" — 1 round trip
|
||||
|
||||
```
|
||||
doctor → covers health + auth + connectivity
|
||||
# status + stats only if doctor reveals issues
|
||||
```
|
||||
|
||||
### Flow 6 Optimized: "Find and understand" — 2 round trips
|
||||
|
||||
```
|
||||
search "query" -n 5 → discover entities
|
||||
issues --batch 42,55,71 --include timeline → batch detail with events
|
||||
```
|
||||
198
docs/command-surface-analysis/07-consolidation-proposals.md
Normal file
198
docs/command-surface-analysis/07-consolidation-proposals.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Consolidation Proposals
|
||||
|
||||
5 proposals to reduce 34 commands to 29 by merging high-overlap commands.
|
||||
|
||||
---
|
||||
|
||||
## A. Absorb `file-history` into `trace --shallow`
|
||||
|
||||
**Overlap:** 75%. Both do rename chain BFS on `mr_file_changes`, both optionally include DiffNote discussions. `trace` follows `entity_references` to linked issues; `file-history` stops at MRs.
|
||||
|
||||
**Current state:**
|
||||
```bash
|
||||
# These do nearly the same thing:
|
||||
lore file-history src/auth/ -p proj --discussions
|
||||
lore trace src/auth/ -p proj --discussions
|
||||
# trace just adds: issues linked via entity_references
|
||||
```
|
||||
|
||||
**Proposed change:**
|
||||
- `trace <path>` — full chain: file -> MR -> issue -> discussions (existing behavior)
|
||||
- `trace <path> --shallow` — MR-only, no issue following (replaces `file-history`)
|
||||
- Move `--merged` flag from `file-history` to `trace`
|
||||
- Deprecate `file-history` as an alias that maps to `trace --shallow`
|
||||
|
||||
**Migration path:**
|
||||
1. Add `--shallow` and `--merged` flags to `trace`
|
||||
2. Make `file-history` an alias with deprecation warning
|
||||
3. Update robot-docs to point to `trace`
|
||||
4. Remove alias after 2 releases
|
||||
|
||||
**Breaking changes:** Robot output shape differs slightly (`trace_chains` vs `merge_requests` key name). The `--shallow` variant should match `file-history`'s output shape for compatibility.
|
||||
|
||||
**Effort:** Low. Most code is already shared via `resolve_rename_chain()`.
|
||||
|
||||
---
|
||||
|
||||
## B. Absorb `auth` into `doctor`
|
||||
|
||||
**Overlap:** 100% of `auth` is contained within `doctor`.
|
||||
|
||||
**Current state:**
|
||||
```bash
|
||||
lore auth # checks: token set, GitLab reachable, user identity
|
||||
lore doctor # checks: all of above + DB + schema + Ollama
|
||||
```
|
||||
|
||||
**Proposed change:**
|
||||
- `doctor` — full check (existing behavior)
|
||||
- `doctor --auth` — token + GitLab only (replaces `auth`)
|
||||
- Keep `health` separate (fast pre-flight, different exit code contract: 0/19)
|
||||
- Deprecate `auth` as alias for `doctor --auth`
|
||||
|
||||
**Migration path:**
|
||||
1. Add `--auth` flag to `doctor`
|
||||
2. Make `auth` an alias with deprecation warning
|
||||
3. Remove alias after 2 releases
|
||||
|
||||
**Breaking changes:** None for robot mode (same JSON shape). Exit code mapping needs verification.
|
||||
|
||||
**Effort:** Low. Doctor already has the auth check logic.
|
||||
|
||||
---
|
||||
|
||||
## C. Remove `related` query-mode
|
||||
|
||||
**Overlap:** 80% with `search --mode semantic`.
|
||||
|
||||
**Current state:**
|
||||
```bash
|
||||
# These are functionally equivalent:
|
||||
lore related "authentication flow"
|
||||
lore search "authentication flow" --mode semantic
|
||||
|
||||
# This is UNIQUE (no overlap):
|
||||
lore related issues 42
|
||||
```
|
||||
|
||||
**Proposed change:**
|
||||
- Keep entity-seeded mode: `related issues 42` (seeds from existing entity embedding)
|
||||
- Remove free-text mode: `related "text"` -> error with suggestion: "Use `search --mode semantic`"
|
||||
- Alternatively: keep as sugar but document it as equivalent to search
|
||||
|
||||
**Migration path:**
|
||||
1. Add deprecation warning when query-mode is used
|
||||
2. After 2 releases, remove query-mode parsing
|
||||
3. Entity-mode stays unchanged
|
||||
|
||||
**Breaking changes:** Agents using `related "text"` must switch to `search --mode semantic`. This is a strict improvement since search has filters.
|
||||
|
||||
**Effort:** Low. Just argument validation change.
|
||||
|
||||
---
|
||||
|
||||
## D. Merge `who overlap` into `who expert`
|
||||
|
||||
**Overlap:** 50% functional, but overlap is a strict simplification of expert.
|
||||
|
||||
**Current state:**
|
||||
```bash
|
||||
lore who src/auth/ # expert mode: scored rankings
|
||||
lore who --overlap src/auth/ # overlap mode: raw touch counts
|
||||
```
|
||||
|
||||
**Proposed change:**
|
||||
- `who <path>` (expert) adds `touch_count` and `last_touch_at` fields to each expert row
|
||||
- `who --overlap <path>` becomes an alias for `who <path> --fields username,touch_count`
|
||||
- Eventually remove `--overlap` flag
|
||||
|
||||
**New expert output:**
|
||||
```json
|
||||
{
|
||||
"experts": [
|
||||
{
|
||||
"username": "jdoe", "score": 42.5,
|
||||
"touch_count": 15, "last_touch_at": "2026-02-20",
|
||||
"detail": { "mr_ids_author": [99, 101] }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Migration path:**
|
||||
1. Add `touch_count` and `last_touch_at` to expert output
|
||||
2. Make `--overlap` an alias with deprecation warning
|
||||
3. Remove `--overlap` after 2 releases
|
||||
|
||||
**Breaking changes:** Expert output gains new fields (non-breaking for JSON consumers). Overlap output shape changes if agents were parsing `{ "users": [...] }` vs `{ "experts": [...] }`.
|
||||
|
||||
**Effort:** Low. Expert query already touches the same tables; just need to add a COUNT aggregation.
|
||||
|
||||
---
|
||||
|
||||
## E. Merge `count` and `status` into `stats`
|
||||
|
||||
**Overlap:** `count` and `stats` both answer "how much data?"; `status` and `stats` both report system state.
|
||||
|
||||
**Current state:**
|
||||
```bash
|
||||
lore count issues # entity count + state breakdown
|
||||
lore count mrs # entity count + state breakdown
|
||||
lore status # sync cursors per project
|
||||
lore stats # document/index counts + integrity
|
||||
```
|
||||
|
||||
**Proposed change:**
|
||||
- `stats` — document/index health (existing behavior, default)
|
||||
- `stats --entities` — adds entity counts (replaces `count`)
|
||||
- `stats --sync` — adds sync cursor positions (replaces `status`)
|
||||
- `stats --all` — everything: entities + sync + documents + integrity
|
||||
- `stats --check` / `--repair` — unchanged
|
||||
|
||||
**New `--all` output:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"entities": {
|
||||
"issues": { "total": 5000, "opened": 200, "closed": 4800 },
|
||||
"merge_requests": { "total": 1234, "opened": 100, "closed": 50, "merged": 1084 },
|
||||
"discussions": { "total": 8000 },
|
||||
"notes": { "total": 282000, "system_excluded": 50000 }
|
||||
},
|
||||
"sync": {
|
||||
"projects": [
|
||||
{ "project_path": "group/repo", "last_synced_at": "...", "document_count": 5000 }
|
||||
]
|
||||
},
|
||||
"documents": { "total": 61652, "issues": 5000, "mrs": 2000, "notes": 50000 },
|
||||
"embeddings": { "total": 80000, "synced": 79500, "pending": 500 },
|
||||
"fts": { "total_docs": 61652 },
|
||||
"queues": { "pending": 0, "in_progress": 0, "failed": 0 },
|
||||
"integrity": { "ok": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Migration path:**
|
||||
1. Add `--entities`, `--sync`, `--all` flags to `stats`
|
||||
2. Make `count` an alias for `stats --entities` with deprecation warning
|
||||
3. Make `status` an alias for `stats --sync` with deprecation warning
|
||||
4. Remove aliases after 2 releases
|
||||
|
||||
**Breaking changes:** `count` output currently has `{ "entity": "issues", "count": N, "breakdown": {...} }`. Under `stats --entities`, this becomes nested under `data.entities`. Alias can preserve old shape during deprecation period.
|
||||
|
||||
**Effort:** Medium. Need to compose three query paths into one response builder.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Consolidation | Removes | Effort | Breaking? |
|
||||
|---|---|---|---|
|
||||
| `file-history` -> `trace --shallow` | -1 command | Low | Alias redirect, output shape compat |
|
||||
| `auth` -> `doctor --auth` | -1 command | Low | Alias redirect |
|
||||
| `related` query-mode removal | -1 mode | Low | Must switch to `search --mode semantic` |
|
||||
| `who overlap` -> `who expert` | -1 sub-mode | Low | Output gains fields |
|
||||
| `count` + `status` -> `stats` | -2 commands | Medium | Output nesting changes |
|
||||
|
||||
**Total: 34 commands -> 29 commands.** All changes use deprecation-with-alias pattern for gradual migration.
|
||||
347
docs/command-surface-analysis/08-robot-optimization-proposals.md
Normal file
347
docs/command-surface-analysis/08-robot-optimization-proposals.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Robot-Mode Optimization Proposals
|
||||
|
||||
6 proposals to reduce round trips and token waste for agent consumers.
|
||||
|
||||
---
|
||||
|
||||
## A. `--include` flag for embedded sub-queries (P0)
|
||||
|
||||
**Problem:** The #1 agent inefficiency. Every "understand this entity" workflow requires 3-4 serial round trips: detail + timeline + related + trace.
|
||||
|
||||
**Proposal:** Add `--include` flag to detail commands that embeds sub-query results in the response.
|
||||
|
||||
```bash
|
||||
# Before: 4 round trips, ~12000 tokens
|
||||
lore -J issues 42 -p proj
|
||||
lore -J timeline "issue:42" -p proj --limit 20
|
||||
lore -J related issues 42 -p proj -n 5
|
||||
lore -J trace src/auth/ -p proj
|
||||
|
||||
# After: 1 round trip, ~5000 tokens (sub-queries use reduced limits)
|
||||
lore -J issues 42 -p proj --include timeline,related
|
||||
```
|
||||
|
||||
### Include Matrix
|
||||
|
||||
| Base Command | Valid Includes | Default Limits |
|
||||
|---|---|---|
|
||||
| `issues <iid>` | `timeline`, `related`, `trace` | 20 events, 5 related, 5 chains |
|
||||
| `mrs <iid>` | `timeline`, `related`, `file-changes` | 20 events, 5 related |
|
||||
| `trace <path>` | `experts`, `timeline` | 5 experts, 20 events |
|
||||
| `me` | `detail` (inline top-N item details) | 3 items detailed |
|
||||
| `search` | `detail` (inline top-N result details) | 3 results detailed |
|
||||
|
||||
### Response Shape
|
||||
|
||||
Included data uses `_` prefix to distinguish from base fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"iid": 42, "title": "Fix auth", "state": "opened",
|
||||
"discussions": [...],
|
||||
"_timeline": {
|
||||
"event_count": 15,
|
||||
"events": [...]
|
||||
},
|
||||
"_related": {
|
||||
"similar_entities": [...]
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"elapsed_ms": 200,
|
||||
"_timeline_ms": 45,
|
||||
"_related_ms": 120
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Sub-query errors are non-fatal. If Ollama is down, `_related` returns an error instead of failing the whole request:
|
||||
|
||||
```json
|
||||
{
|
||||
"_related_error": "Ollama unavailable — related results skipped"
|
||||
}
|
||||
```
|
||||
|
||||
### Limit Control
|
||||
|
||||
```bash
|
||||
# Custom limits for included data
|
||||
lore -J issues 42 --include timeline:50,related:10
|
||||
```
|
||||
|
||||
### Round-Trip Savings
|
||||
|
||||
| Workflow | Before | After | Savings |
|
||||
|---|---|---|---|
|
||||
| Understand an issue | 4 calls | 1 call | **75%** |
|
||||
| Why was code changed | 3 calls | 1 call | **67%** |
|
||||
| Find and understand | 4 calls | 2 calls | **50%** |
|
||||
|
||||
**Effort:** High. Each include needs its own sub-query executor, error isolation, and limit enforcement. But the payoff is massive — this single feature halves agent round trips.
|
||||
|
||||
---
|
||||
|
||||
## B. `--depth` control on `me` (P0)
|
||||
|
||||
**Problem:** `me` returns 2000-5000 tokens. Agents checking "do I have work?" only need ~100 tokens.
|
||||
|
||||
**Proposal:** Add `--depth` flag with three levels.
|
||||
|
||||
```bash
|
||||
# Counts only (~100 tokens) — "do I have work?"
|
||||
lore -J me --depth counts
|
||||
|
||||
# Titles (~400 tokens) — "what work do I have?"
|
||||
lore -J me --depth titles
|
||||
|
||||
# Full (current behavior, 2000+ tokens) — "give me everything"
|
||||
lore -J me --depth full
|
||||
lore -J me # same as --depth full
|
||||
```
|
||||
|
||||
### Depth Levels
|
||||
|
||||
| Level | Includes | Typical Tokens |
|
||||
|---|---|---|
|
||||
| `counts` | `summary` block only (counts, no items) | ~100 |
|
||||
| `titles` | summary + item lists with minimal fields (iid, title, attention_state) | ~400 |
|
||||
| `full` | Everything: items, activity, inbox, discussions | ~2000-5000 |
|
||||
|
||||
### Response at `--depth counts`
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"username": "jdoe",
|
||||
"summary": {
|
||||
"project_count": 3,
|
||||
"open_issue_count": 5,
|
||||
"authored_mr_count": 2,
|
||||
"reviewing_mr_count": 1,
|
||||
"needs_attention_count": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response at `--depth titles`
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"username": "jdoe",
|
||||
"summary": { ... },
|
||||
"open_issues": [
|
||||
{ "iid": 42, "title": "Fix auth", "attention_state": "needs_attention" }
|
||||
],
|
||||
"open_mrs_authored": [
|
||||
{ "iid": 99, "title": "Refactor auth", "attention_state": "needs_attention" }
|
||||
],
|
||||
"reviewing_mrs": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Effort:** Low. The data is already available; just need to gate serialization by depth level.
|
||||
|
||||
---
|
||||
|
||||
## C. `--batch` flag for multi-entity detail (P1)
|
||||
|
||||
**Problem:** After search/timeline, agents discover N entity IIDs and need detail on each. Currently N round trips.
|
||||
|
||||
**Proposal:** Add `--batch` flag to `issues` and `mrs` detail mode.
|
||||
|
||||
```bash
|
||||
# Before: 3 round trips
|
||||
lore -J issues 42 -p proj
|
||||
lore -J issues 55 -p proj
|
||||
lore -J issues 71 -p proj
|
||||
|
||||
# After: 1 round trip
|
||||
lore -J issues --batch 42,55,71 -p proj
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"results": [
|
||||
{ "iid": 42, "title": "Fix auth", "state": "opened", ... },
|
||||
{ "iid": 55, "title": "Add SSO", "state": "opened", ... },
|
||||
{ "iid": 71, "title": "Token refresh", "state": "closed", ... }
|
||||
],
|
||||
"errors": [
|
||||
{ "iid": 99, "error": "Not found" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Constraints
|
||||
|
||||
- Max 20 IIDs per batch
|
||||
- Individual errors don't fail the batch (partial results returned)
|
||||
- Works with `--include` for maximum efficiency: `--batch 42,55 --include timeline`
|
||||
- Works with `--fields minimal` for token control
|
||||
|
||||
**Effort:** Medium. Need to loop the existing detail handler and compose results.
|
||||
|
||||
---
|
||||
|
||||
## D. Composite `context` command (P2)
|
||||
|
||||
**Problem:** Agents need full context on an entity but must learn `--include` syntax. A purpose-built command is more discoverable.
|
||||
|
||||
**Proposal:** Add `context` command that returns detail + timeline + related in one call.
|
||||
|
||||
```bash
|
||||
lore -J context issues 42 -p proj
|
||||
lore -J context mrs 99 -p proj
|
||||
```
|
||||
|
||||
### Equivalent To
|
||||
|
||||
```bash
|
||||
lore -J issues 42 -p proj --include timeline,related
|
||||
```
|
||||
|
||||
But with optimized defaults:
|
||||
- Timeline: 20 most recent events, max 3 evidence notes
|
||||
- Related: top 5 entities
|
||||
- Discussions: truncated after 5 threads
|
||||
- Non-fatal: Ollama-dependent parts gracefully degrade
|
||||
|
||||
### Response Shape
|
||||
|
||||
Same as `issues <iid> --include timeline,related` but with the reduced defaults applied.
|
||||
|
||||
### Relationship to `--include`
|
||||
|
||||
`context` is sugar for the most common `--include` pattern. Both mechanisms can coexist:
|
||||
- `context` for the 80% case (agents wanting full entity understanding)
|
||||
- `--include` for custom combinations
|
||||
|
||||
**Effort:** Medium. Thin wrapper around detail + include pipeline.
|
||||
|
||||
---
|
||||
|
||||
## E. `--max-tokens` response budget (P3)
|
||||
|
||||
**Problem:** Response sizes vary wildly (100 to 8000 tokens). Agents can't predict cost in advance.
|
||||
|
||||
**Proposal:** Let agents cap response size. Server truncates to fit.
|
||||
|
||||
```bash
|
||||
lore -J me --max-tokens 500
|
||||
lore -J timeline "feature" --max-tokens 1000
|
||||
lore -J context issues 42 --max-tokens 2000
|
||||
```
|
||||
|
||||
### Truncation Strategy (priority order)
|
||||
|
||||
1. Apply `--fields minimal` if not already set
|
||||
2. Reduce array lengths (newest/highest-score items survive)
|
||||
3. Truncate string fields (descriptions, snippets) to 200 chars
|
||||
4. Omit null/empty fields
|
||||
5. Drop included sub-queries (if using `--include`)
|
||||
|
||||
### Meta Notice
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"elapsed_ms": 50,
|
||||
"truncated": true,
|
||||
"original_tokens": 3500,
|
||||
"budget_tokens": 1000,
|
||||
"dropped": ["_related", "discussions[5:]", "activity[10:]"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
Token estimation: rough heuristic based on JSON character count / 4. Doesn't need to be exact — the goal is "roughly this size" not "exactly N tokens."
|
||||
|
||||
**Effort:** High. Requires token estimation, progressive truncation logic, and tracking what was dropped.
|
||||
|
||||
---
|
||||
|
||||
## F. `--format tsv` for list commands (P3)
|
||||
|
||||
**Problem:** JSON is verbose for tabular data. List commands return arrays of objects with repeated key names.
|
||||
|
||||
**Proposal:** Add `--format tsv` for list commands.
|
||||
|
||||
```bash
|
||||
lore -J issues --format tsv --fields iid,title,state -n 10
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
iid title state
|
||||
42 Fix auth opened
|
||||
55 Add SSO opened
|
||||
71 Token refresh closed
|
||||
```
|
||||
|
||||
### Token Savings
|
||||
|
||||
| Command | JSON tokens | TSV tokens | Savings |
|
||||
|---|---|---|---|
|
||||
| `issues -n 50 --fields minimal` | ~800 | ~250 | **69%** |
|
||||
| `mrs -n 50 --fields minimal` | ~800 | ~250 | **69%** |
|
||||
| `who expert -n 10` | ~300 | ~100 | **67%** |
|
||||
| `notes -n 50 --fields minimal` | ~1000 | ~350 | **65%** |
|
||||
|
||||
### Applicable Commands
|
||||
|
||||
TSV works well for flat, tabular data:
|
||||
- `issues` (list), `mrs` (list), `notes` (list)
|
||||
- `who expert`, `who overlap`, `who reviews`
|
||||
- `count`
|
||||
|
||||
TSV does NOT work for nested/complex data:
|
||||
- Detail views (discussions are nested)
|
||||
- Timeline (events have nested evidence)
|
||||
- Search (nested explain, labels arrays)
|
||||
- `me` (multiple sections)
|
||||
|
||||
### Agent Parsing
|
||||
|
||||
Most LLMs parse TSV naturally. Agents that need structured data can still use JSON.
|
||||
|
||||
**Effort:** Medium. Tab-separated serialization for flat structs is straightforward. Need to handle escaping for body text containing tabs/newlines.
|
||||
|
||||
---
|
||||
|
||||
## Impact Summary
|
||||
|
||||
| Optimization | Priority | Effort | Round-Trip Savings | Token Savings |
|
||||
|---|---|---|---|---|
|
||||
| `--include` | P0 | High | **50-75%** | Moderate |
|
||||
| `--depth` on `me` | P0 | Low | None | **60-80%** |
|
||||
| `--batch` | P1 | Medium | **N-1 per batch** | Moderate |
|
||||
| `context` command | P2 | Medium | **67-75%** | Moderate |
|
||||
| `--max-tokens` | P3 | High | None | **Variable** |
|
||||
| `--format tsv` | P3 | Medium | None | **65-69% on lists** |
|
||||
|
||||
### Implementation Order
|
||||
|
||||
1. **`--depth` on `me`** — lowest effort, high value, no risk
|
||||
2. **`--include` on `issues`/`mrs` detail** — highest impact, start with `timeline` include only
|
||||
3. **`--batch`** — eliminates N+1 pattern
|
||||
4. **`context` command** — sugar on top of `--include`
|
||||
5. **`--format tsv`** — nice-to-have, easy to add incrementally
|
||||
6. **`--max-tokens`** — complex, defer until demand is clear
|
||||
181
docs/command-surface-analysis/09-appendices.md
Normal file
181
docs/command-surface-analysis/09-appendices.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Appendices
|
||||
|
||||
---
|
||||
|
||||
## A. Robot Output Envelope
|
||||
|
||||
All robot-mode responses follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": { /* command-specific */ },
|
||||
"meta": { "elapsed_ms": 42 }
|
||||
}
|
||||
```
|
||||
|
||||
Errors (to stderr):
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "CONFIG_NOT_FOUND",
|
||||
"message": "Configuration file not found",
|
||||
"suggestion": "Run 'lore init'",
|
||||
"actions": ["lore init"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `actions` array contains copy-paste shell commands for automated recovery. Omitted when empty.
|
||||
|
||||
---
|
||||
|
||||
## B. Exit Codes
|
||||
|
||||
| Code | Meaning | Retryable |
|
||||
|---|---|---|
|
||||
| 0 | Success | N/A |
|
||||
| 1 | Internal error / not implemented | Maybe |
|
||||
| 2 | Usage error (invalid flags or arguments) | No (fix syntax) |
|
||||
| 3 | Config invalid | No (fix config) |
|
||||
| 4 | Token not set | No (set token) |
|
||||
| 5 | GitLab auth failed | Maybe (token expired?) |
|
||||
| 6 | Resource not found (HTTP 404) | No |
|
||||
| 7 | Rate limited | Yes (wait) |
|
||||
| 8 | Network error | Yes (retry) |
|
||||
| 9 | Database locked | Yes (wait) |
|
||||
| 10 | Database error | Maybe |
|
||||
| 11 | Migration failed | No (investigate) |
|
||||
| 12 | I/O error | Maybe |
|
||||
| 13 | Transform error | No (bug) |
|
||||
| 14 | Ollama unavailable | Yes (start Ollama) |
|
||||
| 15 | Ollama model not found | No (pull model) |
|
||||
| 16 | Embedding failed | Yes (retry) |
|
||||
| 17 | Not found (entity does not exist) | No |
|
||||
| 18 | Ambiguous match (use `-p` to specify project) | No (be specific) |
|
||||
| 19 | Health check failed | Yes (fix issues first) |
|
||||
| 20 | Config not found | No (run init) |
|
||||
|
||||
---
|
||||
|
||||
## C. Field Selection Presets
|
||||
|
||||
The `--fields` flag supports both presets and custom field lists:
|
||||
|
||||
```bash
|
||||
lore -J issues --fields minimal # Preset
|
||||
lore -J mrs --fields iid,title,state,draft # Custom comma-separated
|
||||
```
|
||||
|
||||
| Command | Minimal Preset Fields |
|
||||
|---|---|
|
||||
| `issues` (list) | `iid`, `title`, `state`, `updated_at_iso` |
|
||||
| `mrs` (list) | `iid`, `title`, `state`, `updated_at_iso` |
|
||||
| `notes` (list) | `id`, `author_username`, `body`, `created_at_iso` |
|
||||
| `search` | `document_id`, `title`, `source_type`, `score` |
|
||||
| `timeline` | `timestamp`, `type`, `entity_iid`, `detail` |
|
||||
| `who expert` | `username`, `score` |
|
||||
| `who workload` | `iid`, `title`, `state` |
|
||||
| `who reviews` | `name`, `count`, `percentage` |
|
||||
| `who active` | `entity_type`, `iid`, `title`, `participants` |
|
||||
| `who overlap` | `username`, `touch_count` |
|
||||
| `me` (items) | `iid`, `title`, `attention_state`, `updated_at_iso` |
|
||||
| `me` (activity) | `timestamp_iso`, `event_type`, `entity_iid`, `actor` |
|
||||
|
||||
---
|
||||
|
||||
## D. Configuration Precedence
|
||||
|
||||
1. CLI flags (highest priority)
|
||||
2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`)
|
||||
3. Config file (`~/.config/lore/config.json`)
|
||||
4. Built-in defaults (lowest priority)
|
||||
|
||||
---
|
||||
|
||||
## E. Time Parsing
|
||||
|
||||
All commands accepting `--since`, `--until`, `--as-of` support:
|
||||
|
||||
| Format | Example | Meaning |
|
||||
|---|---|---|
|
||||
| Relative days | `7d` | 7 days ago |
|
||||
| Relative weeks | `2w` | 2 weeks ago |
|
||||
| Relative months | `1m`, `6m` | 1/6 months ago |
|
||||
| Absolute date | `2026-01-15` | Specific date |
|
||||
|
||||
Internally converted to Unix milliseconds for DB queries.
|
||||
|
||||
---
|
||||
|
||||
## F. Database Schema (28 migrations)
|
||||
|
||||
### Primary Entity Tables
|
||||
|
||||
| Table | Key Columns | Notes |
|
||||
|---|---|---|
|
||||
| `projects` | `gitlab_project_id`, `path_with_namespace`, `web_url` | No `name` or `last_seen_at` |
|
||||
| `issues` | `iid`, `title`, `state`, `author_username`, 5 status columns | Status columns nullable (migration 021) |
|
||||
| `merge_requests` | `iid`, `title`, `state`, `draft`, `source_branch`, `target_branch` | `last_seen_at INTEGER NOT NULL` |
|
||||
| `discussions` | `gitlab_discussion_id` (text), `issue_id`/`merge_request_id` | One FK must be set |
|
||||
| `notes` | `gitlab_id`, `author_username`, `body`, DiffNote position columns | `type` column for DiffNote/DiscussionNote |
|
||||
|
||||
### Relationship Tables
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `issue_labels`, `mr_labels` | Label junction (DELETE+INSERT for stale removal) |
|
||||
| `issue_assignees`, `mr_assignees` | Assignee junction |
|
||||
| `mr_reviewers` | Reviewer junction |
|
||||
| `entity_references` | Cross-refs: closes, mentioned, related (with `source_method`) |
|
||||
| `mr_file_changes` | File diffs: old_path, new_path, change_type |
|
||||
|
||||
### Event Tables
|
||||
|
||||
| Table | Constraint |
|
||||
|---|---|
|
||||
| `resource_state_events` | CHECK: exactly one of issue_id/merge_request_id NOT NULL |
|
||||
| `resource_label_events` | Same CHECK constraint; `label_name` nullable (migration 012) |
|
||||
| `resource_milestone_events` | Same CHECK constraint; `milestone_title` nullable |
|
||||
|
||||
### Document/Search Pipeline
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `documents` | Unified searchable content (source_type: issue/merge_request/discussion) |
|
||||
| `documents_fts` | FTS5 virtual table for text search |
|
||||
| `documents_fts_docsize` | FTS5 shadow B-tree (19x faster for COUNT) |
|
||||
| `document_labels` | Fast label filtering (indexed exact-match) |
|
||||
| `document_paths` | File path association for DiffNote filtering |
|
||||
| `embeddings` | vec0 virtual table; rowid = document_id * 1000 + chunk_index |
|
||||
| `embedding_metadata` | Chunk provenance + staleness tracking (document_hash) |
|
||||
| `dirty_sources` | Documents needing regeneration (with backoff via next_attempt_at) |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `sync_runs` | Sync history with metrics |
|
||||
| `sync_cursors` | Per-resource sync position (updated_at cursor + tie_breaker_id) |
|
||||
| `app_locks` | Crash-safe single-flight lock |
|
||||
| `raw_payloads` | Raw JSON storage for debugging |
|
||||
| `pending_discussion_fetches` | Dependent discussion fetch queue |
|
||||
| `pending_dependent_fetches` | Job queue for resource_events, mr_closes, mr_diffs |
|
||||
| `schema_version` | Migration tracking |
|
||||
|
||||
---
|
||||
|
||||
## G. Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|---|---|
|
||||
| **IID** | Issue/MR number within a project (not globally unique) |
|
||||
| **FTS5** | SQLite full-text search extension (BM25 ranking) |
|
||||
| **vec0** | SQLite extension for vector similarity search |
|
||||
| **RRF** | Reciprocal Rank Fusion — combines FTS and vector rankings |
|
||||
| **DiffNote** | Comment attached to a specific line in a merge request diff |
|
||||
| **Entity reference** | Cross-reference between issues/MRs (closes, mentioned, related) |
|
||||
| **Rename chain** | BFS traversal of mr_file_changes to follow file renames |
|
||||
| **Attention state** | Computed field on `me` items: needs_attention, not_started, stale, etc. |
|
||||
| **Surgical sync** | Fetching specific entities by IID instead of full incremental sync |
|
||||
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 ...
|
||||
```
|
||||
202
docs/plan-expose-discussion-ids.feedback-1.md
Normal file
202
docs/plan-expose-discussion-ids.feedback-1.md
Normal file
@@ -0,0 +1,202 @@
|
||||
No `## Rejected Recommendations` section appears in the plan you pasted, so the revisions below are all net-new.
|
||||
|
||||
1. **Add an explicit “Bridge Contract” and fix scope inconsistency**
|
||||
Analysis: The plan says “Three changes” but defines four. More importantly, identifier requirements are scattered. A single contract section prevents drift and makes every new read surface prove it can drive a write call.
|
||||
|
||||
```diff
|
||||
@@
|
||||
-**Scope**: Three changes, delivered in order:
|
||||
+**Scope**: Four workstreams, delivered in order:
|
||||
1. Add `gitlab_discussion_id` to notes output
|
||||
2. Add `gitlab_discussion_id` to show command discussion groups
|
||||
3. Add a standalone `discussions` list command
|
||||
4. Fix robot-docs to list actual field names instead of opaque type references
|
||||
+
|
||||
+## Bridge Contract (Cross-Cutting)
|
||||
+Every read payload that surfaces notes/discussions MUST include:
|
||||
+- `project_path`
|
||||
+- `noteable_type`
|
||||
+- `parent_iid`
|
||||
+- `gitlab_discussion_id`
|
||||
+- `gitlab_note_id` (when note-level data is returned)
|
||||
+This contract is required so agents can deterministically construct `glab api` write calls.
|
||||
```
|
||||
|
||||
2. **Normalize identifier naming now (break ambiguous names)**
|
||||
Analysis: Current `id`/`gitlab_id` naming is ambiguous in mixed payloads. Rename to explicit `note_id` and `gitlab_note_id` now (you explicitly don’t care about backward compatibility). This reduces automation mistakes.
|
||||
|
||||
```diff
|
||||
@@ 1b. Add field to `NoteListRow`
|
||||
-pub struct NoteListRow {
|
||||
- pub id: i64,
|
||||
- pub gitlab_id: i64,
|
||||
+pub struct NoteListRow {
|
||||
+ pub note_id: i64, // local DB id
|
||||
+ pub gitlab_note_id: i64, // GitLab note id
|
||||
@@
|
||||
@@ 1c. Add field to `NoteListRowJson`
|
||||
-pub struct NoteListRowJson {
|
||||
- pub id: i64,
|
||||
- pub gitlab_id: i64,
|
||||
+pub struct NoteListRowJson {
|
||||
+ pub note_id: i64,
|
||||
+ pub gitlab_note_id: i64,
|
||||
@@
|
||||
-#### 2f. Add `gitlab_note_id` to note detail structs in show
|
||||
-While we're here, add `gitlab_id` to `NoteDetail`, `MrNoteDetail`, and their JSON
|
||||
+#### 2f. Add `gitlab_note_id` to note detail structs in show
|
||||
+While we're here, add `gitlab_note_id` to `NoteDetail`, `MrNoteDetail`, and their JSON
|
||||
counterparts.
|
||||
```
|
||||
|
||||
3. **Stop positional column indexing for these changes**
|
||||
Analysis: In `list.rs`, row extraction is positional (`row.get(18)`, etc.). Adding fields is fragile and easy to break silently. Use named aliases and named lookup for robustness.
|
||||
|
||||
```diff
|
||||
@@ 1a/1b SQL + query_map
|
||||
- p.path_with_namespace AS project_path
|
||||
+ p.path_with_namespace AS project_path,
|
||||
+ d.gitlab_discussion_id AS gitlab_discussion_id
|
||||
@@
|
||||
- project_path: row.get(18)?,
|
||||
- gitlab_discussion_id: row.get(19)?,
|
||||
+ project_path: row.get("project_path")?,
|
||||
+ gitlab_discussion_id: row.get("gitlab_discussion_id")?,
|
||||
```
|
||||
|
||||
4. **Redesign `discussions` query to avoid correlated subquery fanout**
|
||||
Analysis: Proposed query uses many correlated subqueries per row. That’s acceptable for tiny MR-scoped sets, but degrades for project-wide scans. Use a base CTE + one rollup pass over notes.
|
||||
|
||||
```diff
|
||||
@@ 3c. SQL Query
|
||||
-SELECT
|
||||
- d.id,
|
||||
- ...
|
||||
- (SELECT COUNT(*) FROM notes n2 WHERE n2.discussion_id = d.id AND n2.is_system = 0) AS note_count,
|
||||
- (SELECT n3.author_username FROM notes n3 WHERE n3.discussion_id = d.id ORDER BY n3.position LIMIT 1) AS first_author,
|
||||
- ...
|
||||
-FROM discussions d
|
||||
+WITH base AS (
|
||||
+ SELECT d.id, d.gitlab_discussion_id, d.noteable_type, d.project_id, d.issue_id, d.merge_request_id,
|
||||
+ d.individual_note, d.first_note_at, d.last_note_at, d.resolvable, d.resolved
|
||||
+ FROM discussions d
|
||||
+ {where_sql}
|
||||
+),
|
||||
+note_rollup AS (
|
||||
+ SELECT n.discussion_id,
|
||||
+ COUNT(*) FILTER (WHERE n.is_system = 0) AS user_note_count,
|
||||
+ COUNT(*) AS total_note_count,
|
||||
+ MIN(CASE WHEN n.is_system = 0 THEN n.position END) AS first_user_pos
|
||||
+ FROM notes n
|
||||
+ JOIN base b ON b.id = n.discussion_id
|
||||
+ GROUP BY n.discussion_id
|
||||
+)
|
||||
+SELECT ...
|
||||
+FROM base b
|
||||
+LEFT JOIN note_rollup r ON r.discussion_id = b.id
|
||||
```
|
||||
|
||||
5. **Add explicit index work for new access patterns**
|
||||
Analysis: Existing indexes are good but not ideal for new list patterns (`project + last_note`, note position ordering inside discussion). Add migration entries to keep latency stable.
|
||||
|
||||
```diff
|
||||
@@ ## 3. Add Standalone `discussions` List Command
|
||||
+#### 3h. Add migration for discussion-list performance
|
||||
+**File**: `migrations/027_discussions_list_indexes.sql`
|
||||
+```sql
|
||||
+CREATE INDEX IF NOT EXISTS idx_discussions_project_last_note
|
||||
+ ON discussions(project_id, last_note_at DESC, id DESC);
|
||||
+CREATE INDEX IF NOT EXISTS idx_discussions_project_first_note
|
||||
+ ON discussions(project_id, first_note_at DESC, id DESC);
|
||||
+CREATE INDEX IF NOT EXISTS idx_notes_discussion_position
|
||||
+ ON notes(discussion_id, position);
|
||||
+```
|
||||
```
|
||||
|
||||
6. **Add keyset pagination (critical for agent workflows)**
|
||||
Analysis: `--limit` alone is not enough for automation over large datasets. Add cursor-based pagination with deterministic sort keys and `next_cursor` in JSON.
|
||||
|
||||
```diff
|
||||
@@ 3a. CLI Args
|
||||
+ /// Keyset cursor from previous response
|
||||
+ #[arg(long, help_heading = "Output")]
|
||||
+ pub cursor: Option<String>,
|
||||
@@
|
||||
@@ Response Schema
|
||||
- "total_count": 15,
|
||||
- "showing": 15
|
||||
+ "total_count": 15,
|
||||
+ "showing": 15,
|
||||
+ "next_cursor": "eyJsYXN0X25vdGVfYXQiOjE3MDAwMDAwMDAwMDAsImlkIjoxMjN9"
|
||||
@@
|
||||
@@ Validation Criteria
|
||||
+7. `lore -J discussions ... --cursor <token>` returns the next stable page without duplicates/skips
|
||||
```
|
||||
|
||||
7. **Fix semantic ambiguities in discussion summary fields**
|
||||
Analysis: `note_count` is ambiguous, and `first_author` can accidentally be a system note author. Make fields explicit and consistent with non-system default behavior.
|
||||
|
||||
```diff
|
||||
@@ Response Schema
|
||||
- "note_count": 3,
|
||||
- "first_author": "elovegrove",
|
||||
+ "user_note_count": 3,
|
||||
+ "total_note_count": 4,
|
||||
+ "first_user_author": "elovegrove",
|
||||
@@
|
||||
@@ 3d. Filters struct / path behavior
|
||||
-- `path` → `EXISTS (SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.position_new_path LIKE ?)`
|
||||
+- `path` → match on BOTH `position_new_path` and `position_old_path` (exact/prefix)
|
||||
```
|
||||
|
||||
8. **Enrich show outputs with actionable thread metadata**
|
||||
Analysis: Adding only discussion id helps, but agents still need thread state and note ids to pick targets correctly. Add `resolvable`, `resolved`, `last_note_at_iso`, and `gitlab_note_id` in show discussion payloads.
|
||||
|
||||
```diff
|
||||
@@ 2a/2b show discussion structs
|
||||
pub struct DiscussionDetailJson {
|
||||
pub gitlab_discussion_id: String,
|
||||
+ pub resolvable: bool,
|
||||
+ pub resolved: bool,
|
||||
+ pub last_note_at_iso: String,
|
||||
pub notes: Vec<NoteDetailJson>,
|
||||
@@
|
||||
pub struct NoteDetailJson {
|
||||
+ pub gitlab_note_id: i64,
|
||||
pub author_username: String,
|
||||
```
|
||||
|
||||
9. **Harden robot-docs against schema drift with tests**
|
||||
Analysis: Static JSON in `main.rs` will drift again. Add a lightweight contract test that asserts docs include required fields for `notes`, `discussions`, and show payloads.
|
||||
|
||||
```diff
|
||||
@@ 4. Fix Robot-Docs Response Schemas
|
||||
+#### 4f. Add robot-docs contract tests
|
||||
+**File**: `src/main.rs` (or dedicated test module)
|
||||
+- Assert `robot-docs` contains `gitlab_discussion_id` and `gitlab_note_id` in:
|
||||
+ - `notes.response_schema`
|
||||
+ - `issues.response_schema.show`
|
||||
+ - `mrs.response_schema.show`
|
||||
+ - `discussions.response_schema`
|
||||
```
|
||||
|
||||
10. **Adjust delivery order to reduce rework and include missing CSV path**
|
||||
Analysis: In your sample `handle_discussions`, `csv` is declared in args but not handled. Also, robot-docs should land after all payload changes. Sequence should minimize churn.
|
||||
|
||||
```diff
|
||||
@@ Delivery Order
|
||||
-3. **Change 4** (robot-docs) — depends on 1 and 2 being done so schemas are accurate.
|
||||
-4. **Change 3** (discussions command) — largest change, depends on 1 for design consistency.
|
||||
+3. **Change 3** (discussions command + indexes + pagination) — largest change.
|
||||
+4. **Change 4** (robot-docs + contract tests) — last, after payloads are final.
|
||||
@@ 3e. Handler wiring
|
||||
- match format {
|
||||
+ match format {
|
||||
"json" => ...
|
||||
"jsonl" => ...
|
||||
+ "csv" => print_list_discussions_csv(&result),
|
||||
_ => ...
|
||||
}
|
||||
```
|
||||
|
||||
If you want, I can produce a single consolidated revised plan markdown with these edits applied so you can drop it in directly.
|
||||
162
docs/plan-expose-discussion-ids.feedback-2.md
Normal file
162
docs/plan-expose-discussion-ids.feedback-2.md
Normal file
@@ -0,0 +1,162 @@
|
||||
Best non-rejected upgrades I’d make to this plan are below. They focus on reducing schema drift, making robot output safer to consume, and improving performance behavior at scale.
|
||||
|
||||
1. Add a shared contract model and field constants first (before workstreams 1-4)
|
||||
Rationale: Right now each command has its own structs and ad-hoc mapping. That is exactly how drift happens. A single contract definition reused by `notes`, `show`, `discussions`, and robot-docs gives compile-time coupling between output payloads and docs. It also makes future fields cheaper and safer to add.
|
||||
|
||||
```diff
|
||||
@@ Scope: Four workstreams, delivered in order:
|
||||
-1. Add `gitlab_discussion_id` to notes output
|
||||
-2. Add `gitlab_discussion_id` to show command discussion groups
|
||||
-3. Add a standalone `discussions` list command
|
||||
-4. Fix robot-docs to list actual field names instead of opaque type references
|
||||
+0. Introduce shared Bridge Contract model/constants used by notes/show/discussions/robot-docs
|
||||
+1. Add `gitlab_discussion_id` to notes output
|
||||
+2. Add `gitlab_discussion_id` to show command discussion groups
|
||||
+3. Add a standalone `discussions` list command
|
||||
+4. Fix robot-docs to list actual field names instead of opaque type references
|
||||
|
||||
+## 0. Shared Contract Model (Cross-Cutting)
|
||||
+Define canonical required-field constants and shared mapping helpers, then consume them in:
|
||||
+- `src/cli/commands/list.rs`
|
||||
+- `src/cli/commands/show.rs`
|
||||
+- `src/cli/robot.rs`
|
||||
+- `src/main.rs` robot-docs builder
|
||||
+This removes duplicated field-name strings and prevents docs/output mismatch.
|
||||
```
|
||||
|
||||
2. Make bridge fields “non-droppable” in robot mode
|
||||
Rationale: The current plan adds fields, but `--fields` can still remove them. That breaks the core read/write bridge contract in exactly the workflows this change is trying to fix. In robot mode, contract fields should always be force-included.
|
||||
|
||||
```diff
|
||||
@@ ## Bridge Contract (Cross-Cutting)
|
||||
Every read payload that surfaces notes or discussions **MUST** include:
|
||||
- `project_path`
|
||||
- `noteable_type`
|
||||
- `parent_iid`
|
||||
- `gitlab_discussion_id`
|
||||
- `gitlab_note_id` (when note-level data is returned — i.e., in notes list and show detail)
|
||||
|
||||
+### Field Filtering Guardrail
|
||||
+In robot mode, `filter_fields` must force-include Bridge Contract fields even when users pass a narrower `--fields` list.
|
||||
+Human/table mode keeps existing behavior.
|
||||
```
|
||||
|
||||
3. Replace correlated subqueries in `discussions` rollup with a single-pass window/aggregate pattern
|
||||
Rationale: Your CTE is better than naive fanout, but it still uses multiple correlated sub-selects per discussion for first author/body/path. At 200K+ discussions this can regress badly depending on cache/index state. A window-ranked `notes` CTE with grouped aggregates is usually faster and more predictable in SQLite.
|
||||
|
||||
```diff
|
||||
@@ #### 3c. SQL Query
|
||||
-Core query uses a CTE + rollup to avoid correlated subquery fanout on larger result sets:
|
||||
+Core query uses a CTE + ranked-notes rollup (window function) to avoid per-row correlated subqueries:
|
||||
|
||||
-WITH filtered_discussions AS (...),
|
||||
-note_rollup AS (
|
||||
- SELECT
|
||||
- n.discussion_id,
|
||||
- SUM(...) AS note_count,
|
||||
- (SELECT ... LIMIT 1) AS first_author,
|
||||
- (SELECT ... LIMIT 1) AS first_note_body,
|
||||
- (SELECT ... LIMIT 1) AS position_new_path,
|
||||
- (SELECT ... LIMIT 1) AS position_new_line
|
||||
- FROM notes n
|
||||
- ...
|
||||
-)
|
||||
+WITH filtered_discussions AS (...),
|
||||
+ranked_notes AS (
|
||||
+ SELECT
|
||||
+ n.*,
|
||||
+ ROW_NUMBER() OVER (PARTITION BY n.discussion_id ORDER BY n.position, n.id) AS rn
|
||||
+ FROM notes n
|
||||
+ WHERE n.discussion_id IN (SELECT id FROM filtered_discussions)
|
||||
+),
|
||||
+note_rollup AS (
|
||||
+ SELECT
|
||||
+ discussion_id,
|
||||
+ SUM(CASE WHEN is_system = 0 THEN 1 ELSE 0 END) AS note_count,
|
||||
+ MAX(CASE WHEN rn = 1 AND is_system = 0 THEN author_username END) AS first_author,
|
||||
+ MAX(CASE WHEN rn = 1 AND is_system = 0 THEN body END) AS first_note_body,
|
||||
+ MAX(CASE WHEN position_new_path IS NOT NULL THEN position_new_path END) AS position_new_path,
|
||||
+ MAX(CASE WHEN position_new_line IS NOT NULL THEN position_new_line END) AS position_new_line
|
||||
+ FROM ranked_notes
|
||||
+ GROUP BY discussion_id
|
||||
+)
|
||||
```
|
||||
|
||||
4. Add direct GitLab ID filters for deterministic bridging
|
||||
Rationale: Bridge workflows often start from one known ID. You already have `gitlab_note_id` in notes filters, but discussion filtering still looks internal-ID-centric. Add explicit GitLab-ID filters so agents do not need extra translation calls.
|
||||
|
||||
```diff
|
||||
@@ #### 3a. CLI Args
|
||||
pub struct DiscussionsArgs {
|
||||
+ /// Filter by GitLab discussion ID
|
||||
+ #[arg(long, help_heading = "Filters")]
|
||||
+ pub gitlab_discussion_id: Option<String>,
|
||||
@@
|
||||
|
||||
@@ #### 3d. Filters struct
|
||||
pub struct DiscussionListFilters {
|
||||
+ pub gitlab_discussion_id: Option<String>,
|
||||
@@
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
@@ ## 1. Add `gitlab_discussion_id` to Notes Output
|
||||
+#### 1g. Add `--gitlab-discussion-id` filter to notes
|
||||
+Allow filtering notes directly by GitLab thread ID (not only internal discussion ID).
|
||||
+This enables one-hop note retrieval from external references.
|
||||
```
|
||||
|
||||
5. Add optional note expansion to `discussions` for fewer round-trips
|
||||
Rationale: Today the agent flow is often `discussions -> show`. Optional embedded notes (`--include-notes N`) gives a fast path for “list unresolved threads with latest context” without forcing full show payloads.
|
||||
|
||||
```diff
|
||||
@@ ### Design
|
||||
lore -J discussions --for-mr 99 --resolution unresolved
|
||||
+lore -J discussions --for-mr 99 --resolution unresolved --include-notes 2
|
||||
|
||||
@@ #### 3a. CLI Args
|
||||
+ /// Include up to N latest notes per discussion (0 = none)
|
||||
+ #[arg(long, default_value = "0", help_heading = "Output")]
|
||||
+ pub include_notes: usize,
|
||||
```
|
||||
|
||||
6. Upgrade robot-docs from string blobs to structured schema + explicit contract block
|
||||
Rationale: `contains("gitlab_discussion_id")` tests on schema strings are brittle. A structured schema object gives machine-checked docs and reliable test assertions. Add a contract section for agent consumers.
|
||||
|
||||
```diff
|
||||
@@ ## 4. Fix Robot-Docs Response Schemas
|
||||
-#### 4a. Notes response_schema
|
||||
-Replace stringly-typed schema snippets...
|
||||
+#### 4a. Notes response_schema (structured)
|
||||
+Represent response fields as JSON objects (field -> type/nullable), not freeform strings.
|
||||
|
||||
+#### 4g. Add `bridge_contract` section in robot-docs
|
||||
+Publish canonical required fields per entity:
|
||||
+- notes
|
||||
+- discussions
|
||||
+- show.discussions
|
||||
+- show.notes
|
||||
```
|
||||
|
||||
7. Strengthen validation: add CLI-level contract tests and perf guardrails
|
||||
Rationale: Most current tests are unit-level struct/query checks. Add end-to-end JSON contract tests via command handlers, plus a benchmark-style regression test (ignored by default) so performance work stays intentional.
|
||||
|
||||
```diff
|
||||
@@ ## Validation Criteria
|
||||
8. Bridge Contract fields (...) are present in every applicable read payload
|
||||
+9. Contract fields remain present even with `--fields` in robot mode
|
||||
+10. `discussions` query meets performance guardrail on representative fixture (documented threshold)
|
||||
|
||||
@@ ### Tests
|
||||
+#### Test: robot-mode fields cannot drop bridge contract keys
|
||||
+Run notes/discussions JSON output through `filter_fields` path and assert required keys remain.
|
||||
+
|
||||
+#### Test: CLI contract integration
|
||||
+Invoke command handlers for `notes`, `discussions`, `mrs <iid>`, parse JSON, assert required keys and types.
|
||||
+
|
||||
+#### Test (ignored): large-fixture performance regression
|
||||
+Generate representative fixture and assert `query_discussions` stays under target elapsed time.
|
||||
```
|
||||
|
||||
If you want, I can now produce a full “v2 plan” document that applies these diffs end-to-end (including revised delivery order and complete updated sections).
|
||||
147
docs/plan-expose-discussion-ids.feedback-3.md
Normal file
147
docs/plan-expose-discussion-ids.feedback-3.md
Normal file
@@ -0,0 +1,147 @@
|
||||
1. **Make `gitlab_note_id` explicit in all note-level payloads without breaking existing consumers**
|
||||
Rationale: Your Bridge Contract already requires `gitlab_note_id`, but current plan keeps `gitlab_id` only in `notes` list while adding `gitlab_note_id` only in `show`. That forces agents to special-case commands. Add `gitlab_note_id` as an alias field everywhere note-level data appears, while keeping `gitlab_id` for compatibility.
|
||||
|
||||
```diff
|
||||
@@ Bridge Contract (Cross-Cutting)
|
||||
-Every read payload that surfaces notes or discussions MUST include:
|
||||
+Every read payload that surfaces notes or discussions MUST include:
|
||||
- project_path
|
||||
- noteable_type
|
||||
- parent_iid
|
||||
- gitlab_discussion_id
|
||||
- gitlab_note_id (when note-level data is returned — i.e., in notes list and show detail)
|
||||
+ - Back-compat rule: note payloads may continue exposing `gitlab_id`, but MUST also expose `gitlab_note_id` with the same value.
|
||||
|
||||
@@ 1. Add `gitlab_discussion_id` to Notes Output
|
||||
-#### 1c. Add field to `NoteListRowJson`
|
||||
+#### 1c. Add fields to `NoteListRowJson`
|
||||
+Add `gitlab_note_id` alias in addition to existing `gitlab_id` (no rename, no breakage).
|
||||
|
||||
@@ 1f. Update `--fields minimal` preset
|
||||
-"notes" => ["id", "author_username", "body", "created_at_iso", "gitlab_discussion_id"]
|
||||
+"notes" => ["id", "gitlab_note_id", "author_username", "body", "created_at_iso", "gitlab_discussion_id"]
|
||||
```
|
||||
|
||||
2. **Avoid duplicate flag semantics for discussion filtering**
|
||||
Rationale: `notes` already has `--discussion-id` and it already maps to `d.gitlab_discussion_id`. Adding a second independent flag/field (`--gitlab-discussion-id`) increases complexity and precedence bugs. Keep one backing filter field and make the new flag an alias.
|
||||
|
||||
```diff
|
||||
@@ 1g. Add `--gitlab-discussion-id` filter to notes
|
||||
-Allow filtering notes directly by GitLab discussion thread ID...
|
||||
+Normalize discussion ID flags:
|
||||
+- Keep one backing filter field (`discussion_id`)
|
||||
+- Support both `--discussion-id` (existing) and `--gitlab-discussion-id` (alias)
|
||||
+- If both are provided, clap should reject as duplicate/alias conflict
|
||||
```
|
||||
|
||||
3. **Add ambiguity guardrails for cross-project discussion IDs**
|
||||
Rationale: `gitlab_discussion_id` is unique per project, not globally. Filtering by discussion ID without project can return multiple rows across repos, which breaks deterministic write bridging. Fail fast with an `Ambiguous` error and actionable fix (`--project`).
|
||||
|
||||
```diff
|
||||
@@ Bridge Contract (Cross-Cutting)
|
||||
+### Ambiguity Guardrail
|
||||
+When filtering by `gitlab_discussion_id` without `--project`, if multiple projects match:
|
||||
+- return `Ambiguous` error
|
||||
+- include matching project paths in message
|
||||
+- suggest retry with `--project <path>`
|
||||
```
|
||||
|
||||
4. **Replace `--include-notes` N+1 retrieval with one batched top-N query**
|
||||
Rationale: The current plan’s per-discussion follow-up query scales poorly and creates latency spikes. Use a single window-function query over selected discussion IDs and group rows in Rust. This is both faster and more predictable.
|
||||
|
||||
```diff
|
||||
@@ 3c-ii. Note expansion query (--include-notes)
|
||||
-When `include_notes > 0`, after the main discussion query, run a follow-up query per discussion...
|
||||
+When `include_notes > 0`, run one batched query:
|
||||
+WITH ranked_notes AS (
|
||||
+ SELECT
|
||||
+ n.*,
|
||||
+ d.gitlab_discussion_id,
|
||||
+ ROW_NUMBER() OVER (
|
||||
+ PARTITION BY n.discussion_id
|
||||
+ ORDER BY n.created_at DESC, n.id DESC
|
||||
+ ) AS rn
|
||||
+ FROM notes n
|
||||
+ JOIN discussions d ON d.id = n.discussion_id
|
||||
+ WHERE n.discussion_id IN ( ...selected discussion ids... )
|
||||
+)
|
||||
+SELECT ... FROM ranked_notes WHERE rn <= ?
|
||||
+ORDER BY discussion_id, rn;
|
||||
+
|
||||
+Group by `discussion_id` in Rust and attach notes arrays without per-thread round-trips.
|
||||
```
|
||||
|
||||
5. **Add hard output guardrails and explicit truncation metadata**
|
||||
Rationale: `--limit` and `--include-notes` are unbounded today. For robot workflows this can accidentally generate huge payloads. Cap values and surface effective limits plus truncation state in `meta`.
|
||||
|
||||
```diff
|
||||
@@ 3a. CLI Args
|
||||
- pub limit: usize,
|
||||
+ pub limit: usize, // clamp to max (e.g., 500)
|
||||
|
||||
- pub include_notes: usize,
|
||||
+ pub include_notes: usize, // clamp to max (e.g., 20)
|
||||
|
||||
@@ Response Schema
|
||||
- "meta": { "elapsed_ms": 12 }
|
||||
+ "meta": {
|
||||
+ "elapsed_ms": 12,
|
||||
+ "effective_limit": 50,
|
||||
+ "effective_include_notes": 2,
|
||||
+ "has_more": true
|
||||
+ }
|
||||
```
|
||||
|
||||
6. **Strengthen deterministic ordering and null handling**
|
||||
Rationale: `first_note_at`, `last_note_at`, and note `position` can be null/incomplete during partial sync states. Add null-safe ordering to avoid unstable output and flaky automation.
|
||||
|
||||
```diff
|
||||
@@ 2c. Update queries to SELECT new fields
|
||||
-... ORDER BY first_note_at
|
||||
+... ORDER BY COALESCE(first_note_at, last_note_at, 0), id
|
||||
|
||||
@@ show note query
|
||||
-ORDER BY position
|
||||
+ORDER BY COALESCE(position, 9223372036854775807), created_at, id
|
||||
|
||||
@@ 3c. SQL Query
|
||||
-ORDER BY {sort_column} {order}
|
||||
+ORDER BY COALESCE({sort_column}, 0) {order}, fd.id {order}
|
||||
```
|
||||
|
||||
7. **Make write-bridging more useful with optional command hints**
|
||||
Rationale: Exposing IDs is necessary but not sufficient; agents still need to assemble endpoints repeatedly. Add optional `--with-write-hints` that injects compact endpoint templates (`reply`, `resolve`) derived from row context. This improves usability without bloating default output.
|
||||
|
||||
```diff
|
||||
@@ 3a. CLI Args
|
||||
+ /// Include machine-actionable glab write hints per row
|
||||
+ #[arg(long, help_heading = "Output")]
|
||||
+ pub with_write_hints: bool,
|
||||
|
||||
@@ Response Schema (notes/discussions/show)
|
||||
+ "write_hints?": {
|
||||
+ "reply_endpoint": "string",
|
||||
+ "resolve_endpoint?": "string"
|
||||
+ }
|
||||
```
|
||||
|
||||
8. **Upgrade robot-docs/contract validation from string-contains to parity checks**
|
||||
Rationale: `contains("gitlab_discussion_id")` catches very little and allows schema drift. Build field-set parity tests that compare actual serialized JSON keys to robot-docs declared fields for `notes`, `discussions`, and `show` discussion nodes.
|
||||
|
||||
```diff
|
||||
@@ 4f. Add robot-docs contract tests
|
||||
-assert!(notes_schema.contains("gitlab_discussion_id"));
|
||||
+let declared = parse_schema_field_list(notes_schema);
|
||||
+let sample = sample_notes_row_json_keys();
|
||||
+assert_required_subset(&declared, &["project_path","noteable_type","parent_iid","gitlab_discussion_id","gitlab_note_id"]);
|
||||
+assert_schema_matches_payload(&declared, &sample);
|
||||
|
||||
@@ 4g. Add CLI-level contract integration tests
|
||||
+Add parity tests for:
|
||||
+- notes list JSON
|
||||
+- discussions list JSON
|
||||
+- issues show discussions[*]
|
||||
+- mrs show discussions[*]
|
||||
```
|
||||
|
||||
If you want, I can produce a full revised v3 plan text with these edits merged end-to-end so it’s ready to execute directly.
|
||||
207
docs/plan-expose-discussion-ids.feedback-4.md
Normal file
207
docs/plan-expose-discussion-ids.feedback-4.md
Normal file
@@ -0,0 +1,207 @@
|
||||
Below are the highest-impact revisions I’d make to this plan. I excluded everything listed in your `## Rejected Recommendations` section.
|
||||
|
||||
**1. Fix a correctness bug in the ambiguity guardrail (must run before `LIMIT`)**
|
||||
|
||||
The current post-query ambiguity check can silently fail when `--limit` truncates results to one project even though multiple projects match the same `gitlab_discussion_id`. That creates non-deterministic write targeting risk.
|
||||
|
||||
```diff
|
||||
@@ ## Ambiguity Guardrail
|
||||
-**Implementation**: After the main query, if `gitlab_discussion_id` is set and no `--project`
|
||||
-was provided, check if the result set spans multiple `project_path` values.
|
||||
+**Implementation**: Run a preflight distinct-project check when `gitlab_discussion_id` is set
|
||||
+and `--project` was not provided, before the main list query applies `LIMIT`.
|
||||
+Use:
|
||||
+```sql
|
||||
+SELECT DISTINCT p.path_with_namespace
|
||||
+FROM discussions d
|
||||
+JOIN projects p ON p.id = d.project_id
|
||||
+WHERE d.gitlab_discussion_id = ?
|
||||
+LIMIT 3
|
||||
+```
|
||||
+If more than one project is found, return `LoreError::Ambiguous` (exit code 18) with project
|
||||
+paths and suggestion to retry with `--project <path>`.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**2. Add `gitlab_project_id` to the Bridge Contract**
|
||||
|
||||
`project_path` is human-friendly but mutable (renames/transfers). `gitlab_project_id` gives a stable write target and avoids path re-resolution failures.
|
||||
|
||||
```diff
|
||||
@@ ## Bridge Contract (Cross-Cutting)
|
||||
Every read payload that surfaces notes or discussions **MUST** include:
|
||||
- `project_path`
|
||||
+- `gitlab_project_id`
|
||||
- `noteable_type`
|
||||
- `parent_iid`
|
||||
- `gitlab_discussion_id`
|
||||
- `gitlab_note_id`
|
||||
@@
|
||||
const BRIDGE_FIELDS_NOTES: &[&str] = &[
|
||||
- "project_path", "noteable_type", "parent_iid",
|
||||
+ "project_path", "gitlab_project_id", "noteable_type", "parent_iid",
|
||||
"gitlab_discussion_id", "gitlab_note_id",
|
||||
];
|
||||
const BRIDGE_FIELDS_DISCUSSIONS: &[&str] = &[
|
||||
- "project_path", "noteable_type", "parent_iid",
|
||||
+ "project_path", "gitlab_project_id", "noteable_type", "parent_iid",
|
||||
"gitlab_discussion_id",
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**3. Replace stringly-typed filter/sort fields with enums end-to-end**
|
||||
|
||||
Right now `sort`, `order`, `resolution`, `noteable_type` are mostly `String`. This is fragile and risks unsafe SQL interpolation drift over time. Typed enums make invalid states unrepresentable.
|
||||
|
||||
```diff
|
||||
@@ ## 3a. CLI Args
|
||||
- pub resolution: Option<String>,
|
||||
+ pub resolution: Option<ResolutionFilter>,
|
||||
@@
|
||||
- pub noteable_type: Option<String>,
|
||||
+ pub noteable_type: Option<NoteableTypeFilter>,
|
||||
@@
|
||||
- pub sort: String,
|
||||
+ pub sort: DiscussionSortField,
|
||||
@@
|
||||
- pub asc: bool,
|
||||
+ pub order: SortDirection,
|
||||
@@ ## 3d. Filters struct
|
||||
- pub resolution: Option<String>,
|
||||
- pub noteable_type: Option<String>,
|
||||
- pub sort: String,
|
||||
- pub order: String,
|
||||
+ pub resolution: Option<ResolutionFilter>,
|
||||
+ pub noteable_type: Option<NoteableTypeFilter>,
|
||||
+ pub sort: DiscussionSortField,
|
||||
+ pub order: SortDirection,
|
||||
@@
|
||||
+Map enum -> SQL fragment via `match` in query builder; never interpolate raw strings.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**4. Enforce snapshot consistency for multi-query commands**
|
||||
|
||||
`discussions` with `--include-notes` does multiple reads. Without a single read transaction, concurrent ingest can produce mismatched `total_count`, row set, and expanded notes.
|
||||
|
||||
```diff
|
||||
@@ ## 3c. SQL Query
|
||||
-pub fn query_discussions(...)
|
||||
+pub fn query_discussions(...)
|
||||
{
|
||||
+ // Run count query + page query + note expansion under one deferred read transaction
|
||||
+ // so output is a single consistent snapshot.
|
||||
+ let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
|
||||
...
|
||||
+ tx.commit()?;
|
||||
}
|
||||
@@ ## 1. Add `gitlab_discussion_id` to Notes Output
|
||||
+Apply the same snapshot rule to `query_notes` when returning `total_count` + paged rows.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**5. Correct first-note rollup semantics (current CTE can return null/incorrect `first_author`)**
|
||||
|
||||
In the proposed SQL, `rn=1` is computed over all notes but then filtered with `is_system=0`, so threads with a leading system note may incorrectly lose `first_author`/snippet. Also path rollup uses non-deterministic `MAX(...)`.
|
||||
|
||||
```diff
|
||||
@@ ## 3c. SQL Query
|
||||
-ranked_notes AS (
|
||||
+ranked_notes AS (
|
||||
SELECT
|
||||
n.discussion_id,
|
||||
n.author_username,
|
||||
n.body,
|
||||
n.is_system,
|
||||
n.position_new_path,
|
||||
n.position_new_line,
|
||||
- ROW_NUMBER() OVER (
|
||||
- PARTITION BY n.discussion_id
|
||||
- ORDER BY n.position, n.id
|
||||
- ) AS rn
|
||||
+ ROW_NUMBER() OVER (
|
||||
+ PARTITION BY n.discussion_id
|
||||
+ ORDER BY CASE WHEN n.is_system = 0 THEN 0 ELSE 1 END, n.created_at, n.id
|
||||
+ ) AS rn_first_note,
|
||||
+ ROW_NUMBER() OVER (
|
||||
+ PARTITION BY n.discussion_id
|
||||
+ ORDER BY CASE WHEN n.position_new_path IS NULL THEN 1 ELSE 0 END, n.created_at, n.id
|
||||
+ ) AS rn_first_position
|
||||
@@
|
||||
- MAX(CASE WHEN rn = 1 AND is_system = 0 THEN author_username END) AS first_author,
|
||||
- MAX(CASE WHEN rn = 1 AND is_system = 0 THEN body END) AS first_note_body,
|
||||
- MAX(CASE WHEN position_new_path IS NOT NULL THEN position_new_path END) AS position_new_path,
|
||||
- MAX(CASE WHEN position_new_line IS NOT NULL THEN position_new_line END) AS position_new_line
|
||||
+ MAX(CASE WHEN rn_first_note = 1 AND is_system = 0 THEN author_username END) AS first_author,
|
||||
+ MAX(CASE WHEN rn_first_note = 1 AND is_system = 0 THEN body END) AS first_note_body,
|
||||
+ MAX(CASE WHEN rn_first_position = 1 THEN position_new_path END) AS position_new_path,
|
||||
+ MAX(CASE WHEN rn_first_position = 1 THEN position_new_line END) AS position_new_line
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**6. Add per-discussion truncation signals for `--include-notes`**
|
||||
|
||||
Top-level `has_more` is useful, but agents also need to know if an individual thread’s notes were truncated. Otherwise they can’t tell if a thread is complete.
|
||||
|
||||
```diff
|
||||
@@ ## Response Schema
|
||||
{
|
||||
"gitlab_discussion_id": "...",
|
||||
...
|
||||
- "notes": []
|
||||
+ "included_note_count": 0,
|
||||
+ "has_more_notes": false,
|
||||
+ "notes": []
|
||||
}
|
||||
@@ ## 3b. Domain Structs
|
||||
pub struct DiscussionListRowJson {
|
||||
@@
|
||||
+ pub included_note_count: usize,
|
||||
+ pub has_more_notes: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<NoteListRowJson>,
|
||||
}
|
||||
@@ ## 3c-ii. Note expansion query (--include-notes)
|
||||
-Group by `discussion_id` in Rust and attach notes arrays...
|
||||
+Group by `discussion_id` in Rust, attach notes arrays, and set:
|
||||
+`included_note_count = notes.len()`,
|
||||
+`has_more_notes = note_count > included_note_count`.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**7. Add explicit query-plan gate and targeted index workstream (measured, not speculative)**
|
||||
|
||||
This plan introduces heavy discussion-centric reads. You should bake in deterministic performance validation with `EXPLAIN QUERY PLAN` and only then add indexes if missing.
|
||||
|
||||
```diff
|
||||
@@ ## Scope: Four workstreams, delivered in order:
|
||||
-4. Fix robot-docs to list actual field names instead of opaque type references
|
||||
+4. Add query-plan validation + targeted index updates for new discussion queries
|
||||
+5. Fix robot-docs to list actual field names instead of opaque type references
|
||||
@@
|
||||
+## 4. Query-Plan Validation and Targeted Indexes
|
||||
+
|
||||
+Before and after implementing `query_discussions`, capture `EXPLAIN QUERY PLAN` for:
|
||||
+- `--for-mr <iid> --resolution unresolved`
|
||||
+- `--project <path> --since 7d --sort last_note`
|
||||
+- `--gitlab-discussion-id <id>`
|
||||
+
|
||||
+If plans show table scans on `notes`/`discussions`, add indexes in `MIGRATIONS` array:
|
||||
+- `discussions(project_id, gitlab_discussion_id)`
|
||||
+- `discussions(merge_request_id, last_note_at, id)`
|
||||
+- `notes(discussion_id, created_at DESC, id DESC)`
|
||||
+- `notes(discussion_id, position, id)`
|
||||
+
|
||||
+Tests: assert the new query paths return expected rows under indexed schema and no regressions.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you want, I can produce a single consolidated “iteration 4” version of the plan text with all seven revisions merged in place.
|
||||
160
docs/plan-expose-discussion-ids.feedback-4.md.bak
Normal file
160
docs/plan-expose-discussion-ids.feedback-4.md.bak
Normal file
@@ -0,0 +1,160 @@
|
||||
I reviewed the plan end-to-end and focused only on new improvements (none of the items in `## Rejected Recommendations` are re-proposed).
|
||||
|
||||
1. Add direct `--discussion-id` retrieval paths
|
||||
Rationale: This removes a full discovery hop for the exact workflow that failed (replying to a known thread). It also reduces ambiguity and query cost when an agent already has the thread ID.
|
||||
|
||||
```diff
|
||||
@@ Core Changes
|
||||
| 7 | Fix robot-docs to list actual field names | Docs | Small |
|
||||
+| 8 | Add direct `--discussion-id` filter to notes/discussions/show | Core | Small |
|
||||
|
||||
@@ Change 3: Add Standalone `discussions` List Command
|
||||
lore -J discussions --for-mr 99 --cursor <token> # keyset pagination
|
||||
+lore -J discussions --discussion-id 6a9c1750b37d... # direct lookup
|
||||
|
||||
@@ 3a. CLI Args
|
||||
+ #[arg(long, conflicts_with_all = ["for_issue", "for_mr"], help_heading = "Filters")]
|
||||
+ pub discussion_id: Option<String>,
|
||||
|
||||
@@ Change 1: Add `gitlab_discussion_id` to Notes Output
|
||||
+Add `--discussion-id <hex>` filter to `notes` for direct note retrieval within one thread.
|
||||
```
|
||||
|
||||
2. Add a shared filter compiler to eliminate count/query drift
|
||||
Rationale: The plan currently repeats filters across data query, `total_count`, and `incomplete_rows` count queries. That is a classic reliability bug source. A single compiled filter object makes count semantics provably consistent.
|
||||
|
||||
```diff
|
||||
@@ Count Semantics (Cross-Cutting Convention)
|
||||
+## Filter Compiler (NEW, Cross-Cutting Convention)
|
||||
+All list commands must build predicates via a shared `CompiledFilters` object that emits:
|
||||
+- SQL predicate fragment
|
||||
+- bind parameters
|
||||
+- canonical filter string (for cursor hash)
|
||||
+The same compiled object is reused by:
|
||||
+- page data query
|
||||
+- `total_count` query
|
||||
+- `incomplete_rows` query
|
||||
```
|
||||
|
||||
3. Harden keyset pagination semantics for `DESC`, limits, and client ergonomics
|
||||
Rationale: `(sort_value, id) > (?, ?)` is only correct for ascending order. Descending sort needs `<`. Also add explicit `has_more` so clients don’t infer from cursor nullability.
|
||||
|
||||
```diff
|
||||
@@ Keyset Pagination (Cross-Cutting, Change B)
|
||||
-```sql
|
||||
-WHERE (sort_value, id) > (?, ?)
|
||||
-```
|
||||
+Use comparator by order:
|
||||
+- ASC: `(sort_value, id) > (?, ?)`
|
||||
+- DESC: `(sort_value, id) < (?, ?)`
|
||||
|
||||
@@ 3a. CLI Args
|
||||
+ #[arg(short = 'n', long = "limit", default_value = "50", value_parser = clap::value_parser!(usize).range(1..=500), help_heading = "Output")]
|
||||
+ pub limit: usize,
|
||||
|
||||
@@ Response Schema
|
||||
- "next_cursor": "aW...xyz=="
|
||||
+ "next_cursor": "aW...xyz==",
|
||||
+ "has_more": true
|
||||
```
|
||||
|
||||
4. Add DB-level entity integrity invariants (not just response invariants)
|
||||
Rationale: Response-side filtering is good, but DB correctness should also be guarded. This prevents silent corruption and bad joins from ingestion or future migrations.
|
||||
|
||||
```diff
|
||||
@@ Contract Invariants (NEW)
|
||||
+### Entity Integrity Invariants (DB + Ingest)
|
||||
+1. `discussions` must belong to exactly one parent (`issue_id XOR merge_request_id`).
|
||||
+2. `discussions.noteable_type` must match the populated parent column.
|
||||
+3. Natural-key uniqueness is enforced where valid:
|
||||
+ - `(project_id, gitlab_discussion_id)` unique for discussions.
|
||||
+4. Ingestion must reject/quarantine rows violating invariants and report counts.
|
||||
|
||||
@@ Supporting Indexes (Cross-Cutting, Change D)
|
||||
+CREATE UNIQUE INDEX IF NOT EXISTS idx_discussions_project_gitlab_discussion_id
|
||||
+ ON discussions(project_id, gitlab_discussion_id);
|
||||
```
|
||||
|
||||
5. Switch bulk note loading to streaming grouping (avoid large intermediate vecs)
|
||||
Rationale: Current bulk strategy still materializes all notes before grouping. Streaming into the map cuts peak memory and improves large-MR stability.
|
||||
|
||||
```diff
|
||||
@@ Change 2e. Constructor — use bulk notes map
|
||||
-let all_note_rows: Vec<MrNoteDetail> = ... // From bulk query above
|
||||
-let notes_by_discussion: HashMap<i64, Vec<MrNoteDetail>> =
|
||||
- all_note_rows.into_iter().fold(HashMap::new(), |mut map, note| {
|
||||
- map.entry(note.discussion_id).or_insert_with(Vec::new).push(note);
|
||||
- map
|
||||
- });
|
||||
+let mut notes_by_discussion: HashMap<i64, Vec<MrNoteDetail>> = HashMap::new();
|
||||
+for row in bulk_note_stmt.query_map(params, map_note_row)? {
|
||||
+ let note = row?;
|
||||
+ notes_by_discussion.entry(note.discussion_id).or_default().push(note);
|
||||
+}
|
||||
```
|
||||
|
||||
6. Make freshness tri-state (`fresh|stale|unknown`) and fail closed on unknown with `--require-fresh`
|
||||
Rationale: `stale: bool` alone cannot represent “never synced / unknown project freshness.” For write safety, unknown freshness should be explicit and reject under freshness constraints.
|
||||
|
||||
```diff
|
||||
@@ Freshness Metadata & Staleness Guards
|
||||
pub struct ResponseMeta {
|
||||
pub elapsed_ms: i64,
|
||||
pub data_as_of_iso: String,
|
||||
pub sync_lag_seconds: i64,
|
||||
pub stale: bool,
|
||||
+ pub freshness_state: String, // "fresh" | "stale" | "unknown"
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub freshness_reason: Option<String>,
|
||||
pub incomplete_rows: i64,
|
||||
@@
|
||||
-if sync_lag_seconds > max_age_secs {
|
||||
+if freshness_state == "unknown" || sync_lag_seconds > max_age_secs {
|
||||
```
|
||||
|
||||
7. Tune indexes to match actual ORDER BY paths in window queries
|
||||
Rationale: `idx_notes_discussion_position` is likely insufficient for the two window orderings. A covering-style index aligned with partition/order keys reduces random table lookups.
|
||||
|
||||
```diff
|
||||
@@ Supporting Indexes (Cross-Cutting, Change D)
|
||||
--- Notes: window function ORDER BY (discussion_id, position) for ROW_NUMBER()
|
||||
-CREATE INDEX IF NOT EXISTS idx_notes_discussion_position
|
||||
- ON notes(discussion_id, position);
|
||||
+-- Notes: support dual ROW_NUMBER() orderings and reduce table lookups
|
||||
+CREATE INDEX IF NOT EXISTS idx_notes_discussion_window
|
||||
+ ON notes(discussion_id, is_system, position, created_at, gitlab_id);
|
||||
```
|
||||
|
||||
8. Add a phased rollout gate before strict exclusion becomes default
|
||||
Rationale: Enforcing `gitlab_* IS NOT NULL` immediately can hide data if existing rows are incomplete. A short observation gate prevents sudden regressions while preserving the end-state contract.
|
||||
|
||||
```diff
|
||||
@@ Delivery Order
|
||||
+Batch 0: Observability gate (NEW)
|
||||
+- Ship `incomplete_rows` and freshness meta first
|
||||
+- Measure incomplete rate across real datasets
|
||||
+- If incomplete ratio <= threshold, enable strict exclusion defaults
|
||||
+- If above threshold, block rollout and fix ingestion quality first
|
||||
+
|
||||
Change 1 (notes output) ──┐
|
||||
```
|
||||
|
||||
9. Add property-based invariants for pagination/count correctness
|
||||
Rationale: Your current tests are scenario-based and good, but randomized property tests are much better at catching edge-case cursor/count bugs.
|
||||
|
||||
```diff
|
||||
@@ Tests (Change 3 / Change B)
|
||||
+**Test 12**: Property-based pagination invariants (`proptest`)
|
||||
+```rust
|
||||
+#[test]
|
||||
+fn prop_discussion_cursor_no_overlap_no_gap_under_random_data() { /* ... */ }
|
||||
+```
|
||||
+
|
||||
+**Test 13**: Property-based count invariants
|
||||
+```rust
|
||||
+#[test]
|
||||
+fn prop_total_count_and_incomplete_rows_match_filter_partition() { /* ... */ }
|
||||
+```
|
||||
```
|
||||
|
||||
If you want, I can now produce a fully consolidated “Plan v4” that applies these diffs cleanly into your original document so it reads as a single coherent spec.
|
||||
140
docs/plan-expose-discussion-ids.feedback-5.md
Normal file
140
docs/plan-expose-discussion-ids.feedback-5.md
Normal file
@@ -0,0 +1,140 @@
|
||||
Your iteration 4 plan is already strong. The highest-impact revisions are around query shape, transaction boundaries, and contract stability for agents.
|
||||
|
||||
1. **Switch discussions query to a two-phase page-first architecture**
|
||||
Analysis: Current `ranked_notes` runs over every filtered discussion before `LIMIT`, which can explode on project-wide queries. A page-first plan keeps complexity proportional to `limit`, improves tail latency, and reduces memory churn.
|
||||
```diff
|
||||
@@ ## 3c. SQL Query
|
||||
-Core query uses a CTE + ranked-notes rollup (window function) to avoid per-row correlated
|
||||
-subqueries.
|
||||
+Core query is split into two phases for scalability:
|
||||
+1) `paged_discussions` applies filters/sort/LIMIT and returns only page IDs.
|
||||
+2) Note rollups and optional `--include-notes` expansion run only for those page IDs.
|
||||
+This bounds note scanning to visible results and stabilizes latency on large projects.
|
||||
|
||||
-WITH filtered_discussions AS (
|
||||
+WITH filtered_discussions AS (
|
||||
...
|
||||
),
|
||||
-ranked_notes AS (
|
||||
+paged_discussions AS (
|
||||
+ SELECT id
|
||||
+ FROM filtered_discussions
|
||||
+ ORDER BY COALESCE({sort_column}, 0) {order}, id {order}
|
||||
+ LIMIT ?
|
||||
+),
|
||||
+ranked_notes AS (
|
||||
...
|
||||
- WHERE n.discussion_id IN (SELECT id FROM filtered_discussions)
|
||||
+ WHERE n.discussion_id IN (SELECT id FROM paged_discussions)
|
||||
)
|
||||
```
|
||||
|
||||
2. **Move snapshot transaction ownership to handlers (not query helpers)**
|
||||
Analysis: This avoids nested transaction edge cases, keeps function signatures clean, and guarantees one snapshot across count + page + include-notes + serialization metadata.
|
||||
```diff
|
||||
@@ ## Cross-cutting: snapshot consistency
|
||||
-Wrap `query_notes` and `query_discussions` in a deferred read transaction.
|
||||
+Open one deferred read transaction in each handler (`handle_notes`, `handle_discussions`)
|
||||
+and pass `&Transaction` into query helpers. Query helpers do not open/commit transactions.
|
||||
+This guarantees a single snapshot across all subqueries and avoids nested tx pitfalls.
|
||||
|
||||
-pub fn query_discussions(conn: &Connection, ...)
|
||||
+pub fn query_discussions(tx: &rusqlite::Transaction<'_>, ...)
|
||||
```
|
||||
|
||||
3. **Add immutable input filter `--project-id` across notes/discussions/show**
|
||||
Analysis: You already expose `gitlab_project_id` because paths are mutable; input should support the same immutable selector. This removes failure modes after project renames/transfers.
|
||||
```diff
|
||||
@@ ## 3a. CLI Args
|
||||
+ /// Filter by immutable GitLab project ID
|
||||
+ #[arg(long, help_heading = "Filters", conflicts_with = "project")]
|
||||
+ pub project_id: Option<i64>,
|
||||
@@ ## Bridge Contract
|
||||
+Input symmetry rule: commands that accept `--project` should also accept `--project-id`.
|
||||
+If both are present, return usage error (exit code 2).
|
||||
```
|
||||
|
||||
4. **Enforce bridge fields for nested notes in `discussions --include-notes`**
|
||||
Analysis: Current guardrail is entity-level; nested notes can still lose required IDs under aggressive filtering. This is a contract hole for write-bridging.
|
||||
```diff
|
||||
@@ ### Field Filtering Guardrail
|
||||
-In robot mode, `filter_fields` MUST force-include Bridge Contract fields...
|
||||
+In robot mode, `filter_fields` MUST force-include Bridge Contract fields at all returned levels:
|
||||
+- discussion row fields
|
||||
+- nested note fields when `discussions --include-notes` is used
|
||||
|
||||
+const BRIDGE_FIELDS_DISCUSSION_NOTES: &[&str] = &[
|
||||
+ "project_path", "gitlab_project_id", "noteable_type", "parent_iid",
|
||||
+ "gitlab_discussion_id", "gitlab_note_id",
|
||||
+];
|
||||
```
|
||||
|
||||
5. **Make ambiguity preflight scope-aware and machine-actionable**
|
||||
Analysis: Current preflight checks only `gitlab_discussion_id`, which can produce false ambiguity when additional filters already narrow to one project. Also, agents need structured candidates, not only free-text.
|
||||
```diff
|
||||
@@ ### Ambiguity Guardrail
|
||||
-SELECT DISTINCT p.path_with_namespace
|
||||
+SELECT DISTINCT p.path_with_namespace, p.gitlab_project_id
|
||||
FROM discussions d
|
||||
JOIN projects p ON p.id = d.project_id
|
||||
-WHERE d.gitlab_discussion_id = ?
|
||||
+WHERE d.gitlab_discussion_id = ?
|
||||
+ /* plus active scope filters: noteable_type, for_issue/for_mr, since/path when present */
|
||||
LIMIT 3
|
||||
|
||||
-Return LoreError::Ambiguous with message
|
||||
+Return LoreError::Ambiguous with structured details:
|
||||
+`{ code, message, candidates:[{project_path, gitlab_project_id}], suggestion }`
|
||||
```
|
||||
|
||||
6. **Add `--contains` filter to `discussions`**
|
||||
Analysis: This is a high-utility agent workflow gap. Agents frequently need “find thread by text then reply”; forcing a separate `notes` search round-trip is unnecessary.
|
||||
```diff
|
||||
@@ ## 3a. CLI Args
|
||||
+ /// Filter discussions whose notes contain text
|
||||
+ #[arg(long, help_heading = "Filters")]
|
||||
+ pub contains: Option<String>,
|
||||
@@ ## 3d. Filters struct
|
||||
+ pub contains: Option<String>,
|
||||
@@ ## 3d. Where-clause construction
|
||||
+- `path` -> EXISTS (...)
|
||||
+- `path` -> EXISTS (...)
|
||||
+- `contains` -> EXISTS (
|
||||
+ SELECT 1 FROM notes n
|
||||
+ WHERE n.discussion_id = d.id
|
||||
+ AND n.body LIKE ?
|
||||
+ )
|
||||
```
|
||||
|
||||
7. **Promote two baseline indexes from “candidate” to “required”**
|
||||
Analysis: These are directly hit by new primary paths; waiting for post-merge profiling risks immediate perf cliffs in real usage.
|
||||
```diff
|
||||
@@ ## 3h. Query-plan validation
|
||||
-Candidate indexes (add only if EXPLAIN QUERY PLAN shows they're needed):
|
||||
-- discussions(project_id, gitlab_discussion_id)
|
||||
-- notes(discussion_id, created_at DESC, id DESC)
|
||||
+Required baseline indexes for this feature:
|
||||
+- discussions(project_id, gitlab_discussion_id)
|
||||
+- notes(discussion_id, created_at DESC, id DESC)
|
||||
+Keep other indexes conditional on EXPLAIN QUERY PLAN.
|
||||
```
|
||||
|
||||
8. **Add schema versioning and remove contradictory rejected items**
|
||||
Analysis: `robot-docs` contract drift is a long-term agent risk; explicit schema versions let clients fail safely. Also, rejected items currently contradict active sections, which creates implementation ambiguity.
|
||||
```diff
|
||||
@@ ## 4. Fix Robot-Docs Response Schemas
|
||||
"meta": {"elapsed_ms": "int", ...}
|
||||
+"meta": {"elapsed_ms":"int", ..., "schema_version":"string"}
|
||||
+
|
||||
+Schema version policy:
|
||||
+- bump minor on additive fields
|
||||
+- bump major on removals/renames
|
||||
+- expose per-command versions in `robot-docs`
|
||||
@@ ## Rejected Recommendations
|
||||
-- Add `gitlab_note_id` to show-command note detail structs ... rejected ...
|
||||
-- Add `gitlab_discussion_id` to show-command discussion detail structs ... rejected ...
|
||||
-- Add `gitlab_project_id` to show-command discussion detail structs ... rejected ...
|
||||
+Remove stale rejected entries that conflict with accepted workstreams in this plan iteration.
|
||||
```
|
||||
|
||||
If you want, I can produce a fully rewritten iteration 5 plan document that applies all of the above edits cleanly end-to-end.
|
||||
158
docs/plan-expose-discussion-ids.feedback-5.md.bak
Normal file
158
docs/plan-expose-discussion-ids.feedback-5.md.bak
Normal file
@@ -0,0 +1,158 @@
|
||||
I reviewed the whole plan and only proposed changes that are not in your `## Rejected Recommendations`.
|
||||
|
||||
1. **Fix plan-internal inconsistencies first**
|
||||
Analysis: The plan currently has a few self-contradictions (`8` vs `9` cross-cutting improvements, `stale` still referenced after moving to tri-state freshness). Cleaning this prevents implementation drift and bad AC validation.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@
|
||||
-**Scope**: 8 core changes + 8 cross-cutting architectural improvements across 3 tiers:
|
||||
+**Scope**: 8 core changes + 9 cross-cutting architectural improvements across 3 tiers:
|
||||
@@ AC-7: Freshness Metadata Present & Staleness Guards Work
|
||||
-lore -J notes -n 1 | jq '.meta | {data_as_of_iso, sync_lag_seconds, stale}'
|
||||
-# All fields present, stale=false if recently synced
|
||||
+lore -J notes -n 1 | jq '.meta | {data_as_of_iso, sync_lag_seconds, freshness_state}'
|
||||
+# All fields present, freshness_state is one of fresh|stale|unknown
|
||||
@@ Change 6 Response Schema example
|
||||
- "stale": false,
|
||||
+ "freshness_state": "fresh",
|
||||
```
|
||||
|
||||
2. **Require snapshot-consistent list responses (page + counts)**
|
||||
Analysis: `total_count`, `incomplete_rows`, and page rows can drift if sync writes between queries. Enforcing a single read snapshot for all list commands makes pagination and counts deterministic.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ Count Semantics (Cross-Cutting Convention)
|
||||
All list commands use consistent count fields:
|
||||
+All three queries (`page`, `total_count`, `incomplete_rows`) MUST execute inside one read transaction/snapshot.
|
||||
+This guarantees count/page consistency under concurrent sync writes.
|
||||
```
|
||||
|
||||
3. **Use RAII transactions instead of manual `BEGIN/COMMIT`**
|
||||
Analysis: Manual `execute_batch("BEGIN...")` is fragile on early returns. `rusqlite::Transaction` guarantees rollback on error and removes transaction-leak risk.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ Change 2: Consistency guarantee
|
||||
-conn.execute_batch("BEGIN DEFERRED")?;
|
||||
-// ... discussion query ...
|
||||
-// ... bulk note query ...
|
||||
-conn.execute_batch("COMMIT")?;
|
||||
+let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
|
||||
+// ... discussion query ...
|
||||
+// ... bulk note query ...
|
||||
+tx.commit()?;
|
||||
```
|
||||
|
||||
4. **Allow small focused new modules for query infrastructure**
|
||||
Analysis: Keeping everything in `list.rs`/`show.rs` will become a maintenance hotspot as filters/cursors/freshness expand. A small module split reduces coupling and regression risk.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ Change 3: File Architecture
|
||||
-**No new files.** Follow existing patterns:
|
||||
+Allow focused infra modules for shared logic:
|
||||
+- `src/cli/query/filters.rs` (CompiledFilters + builders)
|
||||
+- `src/cli/query/cursor.rs` (encode/decode/validate v2 cursors)
|
||||
+- `src/cli/query/freshness.rs` (freshness computation + guards)
|
||||
+Command handlers remain in existing files.
|
||||
```
|
||||
|
||||
5. **Add ingest-time `discussion_rollups` to avoid repeated heavy window scans**
|
||||
Analysis: Window functions are good, but doing them on every read over large note volumes is still expensive. Precomputing rollups during ingest gives lower and more predictable p95 latency while keeping read paths simpler.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ Architectural Improvements (Cross-Cutting)
|
||||
+| J | Ingest-time discussion rollups (`discussion_rollups`) | Performance | Medium |
|
||||
@@ Change 3 SQL strategy
|
||||
-Use `ROW_NUMBER()` window function instead of correlated subqueries...
|
||||
+Primary path: join precomputed `discussion_rollups` for `note_count`, `first_author`,
|
||||
+`first_note_body`, `position_new_path`, `position_new_line`.
|
||||
+Fallback path: window-function recompute if rollup row is missing (defensive correctness).
|
||||
```
|
||||
|
||||
6. **Add deterministic numeric project selector `--project-id`**
|
||||
Analysis: `-p group/repo` is human-friendly, but numeric project IDs are safer for robots and avoid fuzzy/project-path ambiguity. This reduces false ambiguity failures and lookup overhead.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ DiscussionsArgs
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
+ #[arg(long, conflicts_with = "project", help_heading = "Filters")]
|
||||
+ pub project_id: Option<i64>,
|
||||
@@ Ambiguity handling
|
||||
+If `--project-id` is provided, IID resolution is scoped directly to that project.
|
||||
+`--project-id` takes precedence over path-based project matching.
|
||||
```
|
||||
|
||||
7. **Make path filtering rename-aware (`old` + `new`)**
|
||||
Analysis: Current `--path` strategy only using `position_new_path` misses deleted/renamed-file discussions. Supporting side selection makes the feature materially more useful for review workflows.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ DiscussionsArgs
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub path: Option<String>,
|
||||
+ #[arg(long, value_parser = ["either", "new", "old"], default_value = "either", help_heading = "Filters")]
|
||||
+ pub path_side: String,
|
||||
@@ Change 3 filtering
|
||||
-Path filter matches `position_new_path`.
|
||||
+Path filter semantics:
|
||||
+- `either` (default): match `position_new_path` OR `position_old_path`
|
||||
+- `new`: match only `position_new_path`
|
||||
+- `old`: match only `position_old_path`
|
||||
```
|
||||
|
||||
8. **Add explicit freshness behavior for empty-result queries + bootstrap backfill**
|
||||
Analysis: Freshness based only on “participating rows” is undefined when results are empty. Define deterministic behavior and backfill `project_sync_state` on migration so `unknown` doesn’t spike unexpectedly after deploy.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ Freshness state logic
|
||||
+Empty-result rules:
|
||||
+- If query is project-scoped (`-p` or `--project-id`), freshness is computed from that project even when no rows match.
|
||||
+- If query is unscoped and returns zero rows, freshness is computed from all tracked projects.
|
||||
@@ A1. Track per-project sync timestamp
|
||||
+Migration step: seed `project_sync_state` from latest known sync metadata where available
|
||||
+to avoid mass `unknown` freshness immediately after rollout.
|
||||
```
|
||||
|
||||
9. **Upgrade `--discussion-id` from filter-only to first-class thread retrieval**
|
||||
Analysis: Filtering list output by discussion ID still returns list-shaped data and partial note context. A direct thread retrieval mode is faster for agent workflows and avoids extra commands.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ Core Changes
|
||||
-| 8 | Add direct `--discussion-id` filter to notes/discussions/show | Core | Small |
|
||||
+| 8 | Add direct `--discussion-id` filter + single-thread retrieval mode | Core | Medium |
|
||||
@@ Change 8
|
||||
+lore -J discussions --discussion-id <id> --full-thread
|
||||
+# Returns one discussion with full notes payload (same note schema as show command).
|
||||
```
|
||||
|
||||
10. **Replace ad-hoc AC performance timing with repeatable perf harness**
|
||||
Analysis: `time lore ...` is noisy and machine-dependent. A reproducible seeded benchmark test gives stable guardrails and catches regressions earlier.
|
||||
|
||||
```diff
|
||||
--- a/plan.md
|
||||
+++ b/plan.md
|
||||
@@ AC-10: Performance Budget
|
||||
-time lore -J discussions --for-mr <iid> -n 100
|
||||
-# real 0m0.100s (p95 < 150ms)
|
||||
+cargo test --test perf_discussions -- --ignored --nocapture
|
||||
+# Uses seeded fixture DB and N repeated runs; asserts p95 < 150ms for target query shape.
|
||||
```
|
||||
|
||||
If you want, I can also produce a fully merged “iteration 5” rewritten plan document with these edits applied end-to-end so it’s directly executable by an implementation agent.
|
||||
143
docs/plan-expose-discussion-ids.feedback-6.md.bak
Normal file
143
docs/plan-expose-discussion-ids.feedback-6.md.bak
Normal file
@@ -0,0 +1,143 @@
|
||||
Strong plan overall. The biggest gaps I’d fix are around sync-health correctness, idempotency/integrity under repeated ingests, deleted-entity lifecycle, and reducing schema drift risk without heavy reflection machinery.
|
||||
|
||||
I avoided everything in your `## Rejected Recommendations` section.
|
||||
|
||||
**1. Add Sync Health Semantics (not just age)**
|
||||
Time freshness alone can mislead after partial/failed syncs. Agents need to know whether data is both recent and complete.
|
||||
|
||||
```diff
|
||||
@@ ## Freshness Metadata & Staleness Guards (Cross-Cutting, Change A/F/G)
|
||||
- pub freshness_state: String, // "fresh" | "stale" | "unknown"
|
||||
+ pub freshness_state: String, // "fresh" | "stale" | "unknown"
|
||||
+ pub sync_status: String, // "ok" | "partial" | "failed" | "never"
|
||||
+ pub last_successful_sync_run_id: Option<i64>,
|
||||
+ pub last_attempted_sync_run_id: Option<i64>,
|
||||
@@
|
||||
-#[arg(long, help_heading = "Freshness")]
|
||||
-pub require_fresh: Option<String>,
|
||||
+#[arg(long, help_heading = "Freshness")]
|
||||
+pub require_fresh: Option<String>,
|
||||
+#[arg(long, help_heading = "Freshness")]
|
||||
+pub require_sync_ok: bool,
|
||||
```
|
||||
|
||||
Rationale: this prevents false confidence when one project is fresh-by-time but latest sync actually failed or was partial.
|
||||
|
||||
---
|
||||
|
||||
**2. Add `--require-complete` Guard for Missing Required IDs**
|
||||
You already expose `meta.incomplete_rows`; add a hard gate for automation.
|
||||
|
||||
```diff
|
||||
@@ ## Count Semantics (Cross-Cutting Convention)
|
||||
`incomplete_rows` is computed via a dedicated COUNT query...
|
||||
+Add CLI guard:
|
||||
+`--require-complete` fails with exit code 19 when `meta.incomplete_rows > 0`.
|
||||
+Suggested action: `lore sync --full`.
|
||||
```
|
||||
|
||||
Rationale: agents can fail fast instead of silently acting on partial datasets.
|
||||
|
||||
---
|
||||
|
||||
**3. Strengthen Ingestion Idempotency + Referential Integrity for Notes**
|
||||
You added natural-key uniqueness for discussions; do the same for notes and enforce parent integrity at DB level.
|
||||
|
||||
```diff
|
||||
@@ ## Supporting Indexes (Cross-Cutting, Change D)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_discussions_project_gitlab_discussion_id
|
||||
ON discussions(project_id, gitlab_discussion_id);
|
||||
+CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_gitlab_id
|
||||
+ ON notes(project_id, gitlab_id);
|
||||
+
|
||||
+-- Referential integrity
|
||||
+-- notes.discussion_id REFERENCES discussions(id)
|
||||
+-- notes.project_id REFERENCES projects(id)
|
||||
```
|
||||
|
||||
Rationale: repeated syncs and retries won’t duplicate notes, and orphaned rows can’t accumulate.
|
||||
|
||||
---
|
||||
|
||||
**4. Add Deleted/Tombstoned Entity Lifecycle**
|
||||
Current plan excludes null IDs but doesn’t define behavior when GitLab entities are deleted after sync.
|
||||
|
||||
```diff
|
||||
@@ ## Contract Invariants (NEW)
|
||||
+### Deletion Lifecycle Invariant
|
||||
+1. Notes/discussions deleted upstream are tombstoned locally (`deleted_at`), not hard-deleted.
|
||||
+2. All list/show commands exclude tombstoned rows by default.
|
||||
+3. Optional flag `--include-deleted` exposes tombstoned rows for audit/debug.
|
||||
```
|
||||
|
||||
Rationale: preserves auditability, prevents ghost actions on deleted objects, and avoids destructive resync behavior.
|
||||
|
||||
---
|
||||
|
||||
**5. Expand Discussions Payload for Rename Accuracy + Better Triage**
|
||||
`--path-side old` is great, but output currently only returns `position_new_*`.
|
||||
|
||||
```diff
|
||||
@@ ## Change 3: Add Standalone `discussions` List Command
|
||||
pub position_new_path: Option<String>,
|
||||
pub position_new_line: Option<i64>,
|
||||
+ pub position_old_path: Option<String>,
|
||||
+ pub position_old_line: Option<i64>,
|
||||
+ pub last_author: Option<String>,
|
||||
+ pub participant_usernames: Vec<String>,
|
||||
```
|
||||
|
||||
Rationale: for renamed/deleted files, agents need old and new coordinates to act confidently; participants/last_author improve thread routing and prioritization.
|
||||
|
||||
---
|
||||
|
||||
**6. Add SQLite Busy Handling + Retry Policy**
|
||||
Read transactions + concurrent sync writes can still produce `SQLITE_BUSY` under load.
|
||||
|
||||
```diff
|
||||
@@ ## Count Semantics (Cross-Cutting Convention)
|
||||
**Snapshot consistency**: All three queries ... inside a single read transaction ...
|
||||
+**Busy handling**: set `PRAGMA busy_timeout` (e.g. 5000ms) and retry transient
|
||||
+`SQLITE_BUSY` errors up to 3 times with jittered backoff for read commands.
|
||||
```
|
||||
|
||||
Rationale: improves reliability in real multi-agent usage without changing semantics.
|
||||
|
||||
---
|
||||
|
||||
**7. Make Field Definitions Single-Source (Lightweight Drift Prevention)**
|
||||
You rejected full schema generation from code; a lower-cost middle ground is shared field manifests used by both docs and `--fields` validation.
|
||||
|
||||
```diff
|
||||
@@ ## Change 7: Fix Robot-Docs Response Schemas
|
||||
+#### 7h. Single-source field manifests (no reflection)
|
||||
+Define per-command field constants (e.g. `NOTES_FIELDS`, `DISCUSSIONS_FIELDS`)
|
||||
+used by:
|
||||
+1) `--fields` validation/filtering
|
||||
+2) `--fields minimal` expansion
|
||||
+3) `robot-docs` schema rendering
|
||||
```
|
||||
|
||||
Rationale: cuts drift risk materially while staying much simpler than reflection/snapshot infra.
|
||||
|
||||
---
|
||||
|
||||
**8. De-duplicate and Upgrade Test Strategy Around Concurrency**
|
||||
There are duplicated tests across Change 2 and Change 3; add explicit race tests where sync writes happen between list subqueries to prove tx consistency.
|
||||
|
||||
```diff
|
||||
@@ ## Tests
|
||||
-**Test 6**: `--project-id` scopes IID resolution directly
|
||||
-**Test 7**: `--path-side old` matches renamed file discussions
|
||||
-**Test 8**: `--path-side either` matches both old and new paths
|
||||
+Move shared discussion-filter tests to a single section under Change 3.
|
||||
+Add concurrency tests:
|
||||
+1) count/page/incomplete consistency under concurrent sync writes
|
||||
+2) show discussion+notes snapshot consistency under concurrent writes
|
||||
```
|
||||
|
||||
Rationale: less maintenance noise, better coverage of your highest-risk correctness path.
|
||||
|
||||
---
|
||||
|
||||
If you want, I can also produce a single consolidated patch block that rewrites your plan text end-to-end with these edits applied in-place.
|
||||
2128
docs/plan-expose-discussion-ids.md
Normal file
2128
docs/plan-expose-discussion-ids.md
Normal file
File diff suppressed because it is too large
Load Diff
169
docs/plan-surgical-sync.feedback-3.md
Normal file
169
docs/plan-surgical-sync.feedback-3.md
Normal file
@@ -0,0 +1,169 @@
|
||||
Below are the strongest **new** revisions I’d make (excluding everything in your rejected list), with rationale and plan-level diffs.
|
||||
|
||||
### 1. Add a durable run ledger (`sync_runs`) with phase state
|
||||
This makes surgical sync crash-resumable, auditable, and safer under Ctrl+C. Right now `run_id` is mostly ephemeral; persisting phase state removes ambiguity about what completed.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
+9. **Durable run state**: Surgical sync MUST persist a `sync_runs` row keyed by `run_id`
|
||||
+ with phase transitions (`preflight`, `ingest`, `dependents`, `docs`, `embed`, `done`, `failed`).
|
||||
+ This is required for crash recovery, observability, and deterministic retries.
|
||||
|
||||
@@ Step 9: Create `run_sync_surgical`
|
||||
+Before Stage 0, insert `sync_runs(run_id, project_id, mode='surgical', requested_counts, started_at)`.
|
||||
+After each stage, update `sync_runs.phase`, counters, and `last_error` if present.
|
||||
+On success/failure, set terminal state (`done`/`failed`) and `finished_at`.
|
||||
```
|
||||
|
||||
### 2. Add `--preflight-only` (network validation without writes)
|
||||
`--dry-run` is intentionally zero-network, so it cannot validate IIDs. `--preflight-only` is high-value for agents: verifies existence/permissions quickly with no DB mutation.
|
||||
|
||||
```diff
|
||||
@@ CLI Interface
|
||||
lore sync --dry-run --issue 123 -p myproject
|
||||
+lore sync --preflight-only --issue 123 -p myproject
|
||||
|
||||
@@ Step 2: Add `--issue`, `--mr`, `-p` to `SyncArgs`
|
||||
+ /// Validate remote entities and auth without any DB writes
|
||||
+ #[arg(long, default_value_t = false)]
|
||||
+ pub preflight_only: bool,
|
||||
|
||||
@@ Step 10: Add branch in `run_sync`
|
||||
+if options.preflight_only && options.is_surgical() {
|
||||
+ return run_sync_surgical_preflight_only(config, &options, run_id, signal).await;
|
||||
+}
|
||||
```
|
||||
|
||||
### 3. Preflight should aggregate all missing/failed IIDs, not fail-fast
|
||||
Fail-fast causes repeated reruns. Aggregating errors gives one-shot correction and better robot automation.
|
||||
|
||||
```diff
|
||||
@@ Step 7: Create `src/ingestion/surgical.rs`
|
||||
-/// Returns the fetched payloads. If ANY fetch fails, the entire operation should abort.
|
||||
+/// Returns fetched payloads plus per-IID failures; caller aborts writes if failures exist.
|
||||
pub async fn preflight_fetch(...) -> Result<PreflightResult> {
|
||||
|
||||
@@
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PreflightResult {
|
||||
pub issues: Vec<GitLabIssue>,
|
||||
pub merge_requests: Vec<GitLabMergeRequest>,
|
||||
+ pub failures: Vec<EntityFailure>, // stage="fetch"
|
||||
}
|
||||
|
||||
@@ Step 9: Create `run_sync_surgical`
|
||||
-let preflight = preflight_fetch(...).await?;
|
||||
+let preflight = preflight_fetch(...).await?;
|
||||
+if !preflight.failures.is_empty() {
|
||||
+ result.entity_failures = preflight.failures;
|
||||
+ return Err(LoreError::Other("Surgical preflight failed for one or more IIDs".into()).into());
|
||||
+}
|
||||
```
|
||||
|
||||
### 4. Stop filtering scoped queue drains with raw `json_extract` scans
|
||||
`json_extract(payload_json, '$.scope_run_id')` in hot drain queries will degrade as queue grows. Use indexed scope metadata.
|
||||
|
||||
```diff
|
||||
@@ Step 9b: Implement scoped drain helpers
|
||||
-// claim query adds:
|
||||
-// AND json_extract(payload_json, '$.scope_run_id') = ?
|
||||
+// Add migration:
|
||||
+// 1) Add `scope_run_id` generated/stored column derived from payload_json (or explicit column)
|
||||
+// 2) Create index on (project_id, job_type, scope_run_id, status, id)
|
||||
+// Scoped drains filter by indexed `scope_run_id`, not full-table JSON extraction.
|
||||
```
|
||||
|
||||
### 5. Replace `dirty_source_ids` collection-by-query with explicit run scoping
|
||||
Current approach can accidentally include prior dirty rows for same source and can duplicate work. Tag dirty rows with `origin_run_id` and consume by run.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
-2. **Dirty queue scoping**: ... MUST call ... `run_generate_docs_for_dirty_ids`
|
||||
+2. **Dirty queue scoping**: Surgical sync MUST scope docs by `origin_run_id` on `dirty_sources`
|
||||
+ (or equivalent exact run marker) and MUST NOT drain unrelated dirty rows.
|
||||
|
||||
@@ Step 7: `SurgicalIngestResult`
|
||||
- pub dirty_source_ids: Vec<i64>,
|
||||
+ pub origin_run_id: String,
|
||||
|
||||
@@ Step 9a: Implement `run_generate_docs_for_dirty_ids`
|
||||
-pub fn run_generate_docs_for_dirty_ids(config: &Config, dirty_source_ids: &[i64]) -> Result<...>
|
||||
+pub fn run_generate_docs_for_run_id(config: &Config, run_id: &str) -> Result<...>
|
||||
```
|
||||
|
||||
### 6. Enforce transaction safety at the type boundary
|
||||
`unchecked_transaction()` + `&Connection` signatures is fragile. Accept `&Transaction` for ingest internals and use `TransactionBehavior::Immediate` for deterministic lock behavior.
|
||||
|
||||
```diff
|
||||
@@ Step 7: Create `src/ingestion/surgical.rs`
|
||||
-pub fn ingest_issue_by_iid_from_payload(conn: &Connection, ...)
|
||||
+pub fn ingest_issue_by_iid_from_payload(tx: &rusqlite::Transaction<'_>, ...)
|
||||
|
||||
-pub fn ingest_mr_by_iid_from_payload(conn: &Connection, ...)
|
||||
+pub fn ingest_mr_by_iid_from_payload(tx: &rusqlite::Transaction<'_>, ...)
|
||||
|
||||
-let tx = conn.unchecked_transaction()?;
|
||||
+let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
```
|
||||
|
||||
### 7. Acquire sync lock only for mutation phases, not remote preflight
|
||||
This materially reduces lock contention and keeps normal sync throughput higher, while still guaranteeing mutation serialization.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
+10. **Lock window minimization**: Preflight fetch runs without sync lock; lock is acquired immediately
|
||||
+ before first DB mutation and held through all mutation stages.
|
||||
|
||||
@@ Step 9: Create `run_sync_surgical`
|
||||
-// ── Acquire sync lock ──
|
||||
-...
|
||||
-// ── Stage 0: Preflight fetch ──
|
||||
+// ── Stage 0: Preflight fetch (no lock, no writes) ──
|
||||
...
|
||||
+// ── Acquire sync lock just before Stage 1 mutation ──
|
||||
```
|
||||
|
||||
### 8. Add explicit transient retry policy beyond 429
|
||||
Client already handles rate limits; surgical reliability improves a lot if 5xx/timeouts are retried with bounded backoff.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
+11. **Transient retry policy**: Preflight and dependent remote fetches MUST retry boundedly on
|
||||
+ timeout/5xx with jittered backoff; permanent errors (404/401/403) fail immediately.
|
||||
|
||||
@@ Step 5: Add `get_issue_by_iid` / `get_mr_by_iid`
|
||||
+Document retry behavior for transient transport/server failures.
|
||||
```
|
||||
|
||||
### 9. Tighten automated tests around scoping invariants
|
||||
You already list manual checks; these should be enforced in unit/integration tests to prevent regressions.
|
||||
|
||||
```diff
|
||||
@@ Step 1: TDD — Write Failing Tests First
|
||||
+### 1d. New invariants tests
|
||||
+- `surgical_docs_scope_ignores_preexisting_dirty_rows`
|
||||
+- `scoped_queue_drain_ignores_orphaned_jobs`
|
||||
+- `preflight_aggregates_multiple_missing_iids`
|
||||
+- `preflight_only_performs_zero_writes`
|
||||
+- `dry_run_performs_zero_network_calls`
|
||||
+- `lock_window_does_not_block_during_preflight`
|
||||
|
||||
@@ Acceptance Criteria
|
||||
+32. Scoped queue/docs invariants are covered by automated tests (not manual-only verification).
|
||||
```
|
||||
|
||||
### 10. Make robot-mode surgical output first-class
|
||||
For agent workflows, include full stage telemetry and actionable recovery commands.
|
||||
|
||||
```diff
|
||||
@@ Step 15: Update `SyncResult` for robot mode structured output
|
||||
+ /// Per-stage elapsed ms for deterministic performance tracking
|
||||
+ pub stage_timings_ms: std::collections::BTreeMap<String, u64>,
|
||||
+ /// Suggested recovery commands (robot ergonomics)
|
||||
+ pub recovery_actions: Vec<String>,
|
||||
|
||||
@@ Step 14: Update `robot-docs` manifest
|
||||
+Document surgical-specific error codes and `actions` schema for automated recovery.
|
||||
```
|
||||
|
||||
If you want, I can now produce a fully rewritten **iteration 3** plan that merges these into your current structure end-to-end.
|
||||
212
docs/plan-surgical-sync.feedback-4.md
Normal file
212
docs/plan-surgical-sync.feedback-4.md
Normal file
@@ -0,0 +1,212 @@
|
||||
1. **Resolve the current contract contradictions (`preflight-only`, `dry-run`, `sync_runs`)**
|
||||
|
||||
Why this improves the plan:
|
||||
- Right now constraints conflict: “zero DB writes before commit” vs inserting `sync_runs` during preflight.
|
||||
- This ambiguity will cause implementation drift and flaky acceptance tests.
|
||||
- Splitting control-plane writes from content-plane writes keeps safety guarantees strict while preserving observability.
|
||||
|
||||
```diff
|
||||
@@ ## Design Constraints
|
||||
-6. **Preflight-then-commit**: All remote fetches happen BEFORE any DB writes. If any IID fetch fails (404, network error), the entire operation aborts with zero DB mutations.
|
||||
+6. **Preflight-then-commit (content-plane)**: All remote fetches happen BEFORE any writes to content tables (`issues`, `merge_requests`, `discussions`, `resource_events`, `documents`, `embeddings`).
|
||||
+7. **Control-plane exception**: `sync_runs` / `sync_run_entities` writes are allowed during preflight for observability and crash diagnostics.
|
||||
@@
|
||||
-11. **Preflight-only mode**: `--preflight-only` validates remote entity existence and permissions with zero DB writes.
|
||||
+11. **Preflight-only mode**: `--preflight-only` performs zero content writes; control-plane run-ledger writes are allowed.
|
||||
@@ ### For me to evaluate (functional):
|
||||
-24. **Preflight-only mode** ... no DB mutations beyond the sync_runs ledger entry
|
||||
+24. **Preflight-only mode** ... no content DB mutations; only run-ledger rows may be written
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
2. **Add stale-write protection to avoid TOCTOU regressions during unlocked preflight**
|
||||
|
||||
Why this improves the plan:
|
||||
- You intentionally preflight without lock; that’s good for throughput but introduces race risk.
|
||||
- Without a guard, a slower surgical run can overwrite newer data ingested by a concurrent normal sync.
|
||||
- This is a correctness bug under contention, not a nice-to-have.
|
||||
|
||||
```diff
|
||||
@@ ## Design Constraints
|
||||
+12. **Stale-write protection**: Surgical ingest MUST NOT overwrite fresher local rows. If local `updated_at` is newer than the preflight payload’s `updated_at`, skip that entity and record `skipped_stale`.
|
||||
@@ ## Step 7: Create `src/ingestion/surgical.rs`
|
||||
- let labels_created = process_single_issue(conn, config, project_id, issue)?;
|
||||
+ // Skip stale payloads to avoid TOCTOU overwrite after unlocked preflight.
|
||||
+ if is_local_newer_issue(conn, project_id, issue.iid, issue.updated_at)? {
|
||||
+ result.skipped_stale += 1;
|
||||
+ return Ok(result);
|
||||
+ }
|
||||
+ let labels_created = process_single_issue(conn, config, project_id, issue)?;
|
||||
@@
|
||||
+// same guard for MR path
|
||||
@@ ## Step 15: Update `SyncResult`
|
||||
+ /// Entities skipped because local row was newer than preflight payload
|
||||
+ pub skipped_stale: usize,
|
||||
@@ ### Edge cases to verify:
|
||||
+38. **TOCTOU safety**: if a normal sync updates entity after preflight but before ingest, surgical run skips stale payload (no overwrite)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
3. **Make dirty-source scoping exact (do not capture pre-existing rows for same entity)**
|
||||
|
||||
Why this improves the plan:
|
||||
- Current “query dirty rows by `source_id` after ingest” can accidentally include older dirty rows for the same entity.
|
||||
- That silently violates strict run scoping and can delete unrelated backlog rows.
|
||||
- You can fix this without adding `origin_run_id` to `dirty_sources` (which you already rejected).
|
||||
|
||||
```diff
|
||||
@@ ## Step 7: Create `src/ingestion/surgical.rs`
|
||||
- // Collect dirty_source rows for this entity
|
||||
- let mut stmt = conn.prepare(
|
||||
- "SELECT id FROM dirty_sources WHERE source_type = 'issue' AND source_id = ?1"
|
||||
- )?;
|
||||
+ // Capture only rows inserted by THIS call using high-water mark.
|
||||
+ let before_dirty_id: i64 = conn.query_row(
|
||||
+ "SELECT COALESCE(MAX(id), 0) FROM dirty_sources",
|
||||
+ [], |r| r.get(0),
|
||||
+ )?;
|
||||
+ // ... call process_single_issue ...
|
||||
+ let mut stmt = conn.prepare(
|
||||
+ "SELECT id FROM dirty_sources
|
||||
+ WHERE id > ?1 AND source_type = 'issue' AND source_id = ?2"
|
||||
+ )?;
|
||||
@@
|
||||
+ // same pattern for MR
|
||||
@@ ### 1d. Scoping invariant tests
|
||||
+#[test]
|
||||
+fn surgical_docs_scope_ignores_preexisting_dirty_rows_for_same_entity() {
|
||||
+ // pre-insert dirty row for iid=7, then surgical ingest iid=7
|
||||
+ // assert result.dirty_source_ids only contains newly inserted rows
|
||||
+}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
4. **Fix embed-stage leakage when `--no-docs` is used in surgical mode**
|
||||
|
||||
Why this improves the plan:
|
||||
- Current design can run global embed even when docs stage is skipped, which may embed unrelated backlog docs.
|
||||
- That breaks the surgical “scope only this run” promise.
|
||||
- This is both correctness and operator-trust critical.
|
||||
|
||||
```diff
|
||||
@@ ## Step 9: Create `run_sync_surgical`
|
||||
- if !options.no_embed {
|
||||
+ // Surgical embed only runs when surgical docs actually regenerated docs in this run.
|
||||
+ if !options.no_embed && !options.no_docs && result.documents_regenerated > 0 {
|
||||
@@ ## Step 4: Wire new fields in `handle_sync_cmd`
|
||||
+ if options.is_surgical() && options.no_docs && !options.no_embed {
|
||||
+ return Err(Box::new(LoreError::Other(
|
||||
+ "In surgical mode, --no-docs requires --no-embed (to preserve scoping guarantees)".to_string()
|
||||
+ )));
|
||||
+ }
|
||||
@@ ### For me to evaluate
|
||||
+39. **No embed leakage**: `sync --issue X --no-docs` never embeds unrelated unembedded docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
5. **Add queue-failure hygiene so scoped jobs do not leak forever**
|
||||
|
||||
Why this improves the plan:
|
||||
- Scoped drains prevent accidental processing, but failed runs can strand pending jobs permanently.
|
||||
- You need explicit terminalization (`aborted`) and optional replay mechanics.
|
||||
- Otherwise queue bloat and confusing diagnostics accumulate.
|
||||
|
||||
```diff
|
||||
@@ ## Step 8a: Add `sync_runs` table migration
|
||||
+ALTER TABLE dependent_queue ADD COLUMN aborted_reason TEXT;
|
||||
+-- status domain now includes: pending, claimed, done, failed, aborted
|
||||
@@ ## Step 9: run_sync_surgical failure paths
|
||||
+// On run failure/cancel:
|
||||
+conn.execute(
|
||||
+ "UPDATE dependent_queue
|
||||
+ SET status='aborted', aborted_reason=?1
|
||||
+ WHERE project_id=?2 AND scope_run_id=?3 AND status='pending'",
|
||||
+ rusqlite::params![failure_summary, project_id, run_id],
|
||||
+)?;
|
||||
@@ ## Acceptance Criteria
|
||||
+40. **No stranded scoped jobs**: failed surgical runs leave no `pending` rows for their `scope_run_id`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
6. **Persist per-entity lifecycle (`sync_run_entities`) for real observability and deterministic retry**
|
||||
|
||||
Why this improves the plan:
|
||||
- `sync_runs` alone gives aggregate counters but not which IID failed at which stage.
|
||||
- Per-entity records make retries deterministic and robot output far more useful.
|
||||
- This is the missing piece for your stated “deterministic retry decisions.”
|
||||
|
||||
```diff
|
||||
@@ ## Step 8a: Add `sync_runs` table migration
|
||||
+CREATE TABLE IF NOT EXISTS sync_run_entities (
|
||||
+ id INTEGER PRIMARY KEY,
|
||||
+ run_id TEXT NOT NULL REFERENCES sync_runs(run_id),
|
||||
+ entity_type TEXT NOT NULL CHECK(entity_type IN ('issue','merge_request')),
|
||||
+ iid INTEGER NOT NULL,
|
||||
+ stage TEXT NOT NULL,
|
||||
+ status TEXT NOT NULL CHECK(status IN ('ok','failed','skipped_stale')),
|
||||
+ error_code TEXT,
|
||||
+ error_message TEXT,
|
||||
+ updated_at INTEGER NOT NULL
|
||||
+);
|
||||
+CREATE INDEX IF NOT EXISTS idx_sync_run_entities_run ON sync_run_entities(run_id, entity_type, iid);
|
||||
@@ ## Step 15: Update `SyncResult`
|
||||
+ pub failed_iids: Vec<(String, u64)>,
|
||||
+ pub skipped_stale_iids: Vec<(String, u64)>,
|
||||
@@ ## CLI Interface
|
||||
+lore --robot sync-runs --run-id <id>
|
||||
+lore --robot sync-runs --run-id <id> --retry-failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
7. **Use explicit error type for surgical preflight failures (not `LoreError::Other`)**
|
||||
|
||||
Why this improves the plan:
|
||||
- `Other(String)` loses machine semantics, weakens robot mode, and leads to bad exit-code behavior.
|
||||
- A typed error preserves structured failures and enables actionable recovery commands.
|
||||
|
||||
```diff
|
||||
@@ ## Step 9: run_sync_surgical
|
||||
- return Err(LoreError::Other(
|
||||
- format!("Surgical preflight failed for {} of {} IIDs: {}", ...)
|
||||
- ).into());
|
||||
+ return Err(LoreError::SurgicalPreflightFailed {
|
||||
+ run_id: run_id.to_string(),
|
||||
+ total: total_items,
|
||||
+ failures: preflight.failures.clone(),
|
||||
+ }.into());
|
||||
@@ ## Step 15: Update `SyncResult`
|
||||
+ /// Machine-actionable error summary for robot mode
|
||||
+ pub error_code: Option<String>,
|
||||
@@ ## Acceptance Criteria
|
||||
+41. **Typed failure**: preflight failures serialize structured errors (not generic `Other`) with machine-usable codes/actions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
8. **Strengthen tests for rollback, contention, and stale-skip guarantees**
|
||||
|
||||
Why this improves the plan:
|
||||
- Current tests cover many happy-paths and scoping invariants, but key race/rollback behaviors are still under-tested.
|
||||
- These are exactly where regressions will appear first in production.
|
||||
|
||||
```diff
|
||||
@@ ## Step 1: TDD — Write Failing Tests First
|
||||
+### 1f. Transactional rollback + TOCTOU tests
|
||||
+1. `preflight_success_then_ingest_failure_rolls_back_all_content_writes`
|
||||
+2. `stale_payload_is_skipped_when_local_updated_at_is_newer`
|
||||
+3. `failed_run_aborts_pending_scoped_jobs`
|
||||
+4. `surgical_no_docs_requires_no_embed`
|
||||
@@ ### Automated scoping invariants
|
||||
-38. **Scoped queue/docs invariants are enforced by automated tests**
|
||||
+42. **Rollback and race invariants are enforced by automated tests** (no partial writes on ingest failure, no stale overwrite)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
These eight revisions keep your core approach intact, avoid your explicitly rejected ideas, and close the biggest correctness/operability gaps before implementation.
|
||||
130
docs/plan-surgical-sync.feedback-5.md
Normal file
130
docs/plan-surgical-sync.feedback-5.md
Normal file
@@ -0,0 +1,130 @@
|
||||
**Critical Gaps In Current Plan**
|
||||
1. `dirty_sources` scoping is based on `id`, but `dirty_sources` has no `id` column and uses `(source_type, source_id)` UPSERT semantics.
|
||||
2. Plan assumes a new `dependent_queue` with `status`, but current code uses `pending_dependent_fetches` (delete-on-complete), so queue-scoping design conflicts with existing invariants.
|
||||
3. Constraint 6 says all remote fetches happen before any content writes, but the proposed surgical flow fetches discussions/events/diffs after ingest writes.
|
||||
4. `sync_runs` is already an existing table and already used by `SyncRunRecorder`; the plan currently treats it like a new table.
|
||||
|
||||
**Best Revisions**
|
||||
|
||||
1. **Fix dirty-source scoping to match real schema (queued-at watermark, not `id` high-water).**
|
||||
Why this is better: This removes a correctness bug and makes same-entity re-ingest deterministic under UPSERT behavior.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
-2. Dirty queue scoping: ... capture MAX(id) FROM dirty_sources ... run_generate_docs_for_dirty_ids ...
|
||||
+2. Dirty queue scoping: `dirty_sources` is keyed by `(source_type, source_id)` and updated via UPSERT.
|
||||
+ Surgical scoping MUST use:
|
||||
+ 1) a run-level `run_dirty_floor_ms` captured before surgical ingest, and
|
||||
+ 2) explicit touched source keys from ingest (`(source_type, source_id)`).
|
||||
+ Surgical docs MUST call a scoped API (e.g. `run_generate_docs_for_sources`) and MUST NOT drain global dirty queue.
|
||||
@@ Step 9a
|
||||
-pub fn run_generate_docs_for_dirty_ids(config: &Config, dirty_source_ids: &[i64]) -> Result<GenerateDocsResult>
|
||||
+pub fn run_generate_docs_for_sources(config: &Config, sources: &[(SourceType, i64)]) -> Result<GenerateDocsResult>
|
||||
```
|
||||
|
||||
2. **Bypass shared dependent queue in surgical mode; run dependents inline per target.**
|
||||
Why this is better: Avoids queue migration churn, avoids run-scope conflicts with existing unique constraints, and removes orphan-job hygiene complexity entirely.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
-4. Dependent queue scoping: ... scope_run_id indexed column on dependent_queue ...
|
||||
+4. Surgical dependent execution: surgical mode MUST bypass `pending_dependent_fetches`.
|
||||
+ Dependents (resource_events, mr_closes_issues, mr_diffs) run inline for targeted entities only.
|
||||
+ Global queue remains for normal sync only.
|
||||
@@ Design Constraints
|
||||
-14. Queue failure hygiene: ... pending scoped jobs ... terminalized to aborted ...
|
||||
+14. Surgical failure hygiene: surgical mode MUST leave no queue artifacts because it does not enqueue dependent jobs.
|
||||
@@ Step 9b / 9c / Step 13
|
||||
-Implement scoped drain helpers and enqueue_job scope_run_id plumbing
|
||||
+Replace with direct per-entity helpers in ingestion layer:
|
||||
+ - sync_issue_resource_events_direct(...)
|
||||
+ - sync_mr_resource_events_direct(...)
|
||||
+ - sync_mr_closes_issues_direct(...)
|
||||
+ - sync_mr_diffs_direct(...)
|
||||
```
|
||||
|
||||
3. **Clarify atomicity contract to “primary-entity atomicity” (remove contradiction).**
|
||||
Why this is better: Keeps strong zero-write guarantees for missing IIDs while matching practical staged pipeline behavior.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
-6. Preflight-then-commit (content-plane): All remote fetches happen BEFORE any writes to content tables ...
|
||||
+6. Primary-entity atomicity: all requested issue/MR payload fetches complete before first content write.
|
||||
+ If any primary IID fetch fails, primary ingest does zero content writes.
|
||||
+ Dependent stages (discussions/events/diffs/closes) are post-ingest and best-effort, with structured per-stage failure reporting.
|
||||
```
|
||||
|
||||
4. **Extend existing `sync_runs` schema instead of redefining it.**
|
||||
Why this is better: Preserves compatibility with current `SyncRunRecorder`, `sync_status`, and existing historical data.
|
||||
|
||||
```diff
|
||||
@@ Step 8a
|
||||
-Add `sync_runs` table migration (CREATE TABLE sync_runs ...)
|
||||
+Add migration 027 to extend existing `sync_runs` table:
|
||||
+ - ADD COLUMN mode TEXT NULL -- 'standard' | 'surgical'
|
||||
+ - ADD COLUMN phase TEXT NULL -- preflight|ingest|dependents|docs|embed|done|failed
|
||||
+ - ADD COLUMN surgical_summary_json TEXT NULL
|
||||
+Reuse `SyncRunRecorder` row lifecycle; do not introduce a parallel run-ledger model.
|
||||
```
|
||||
|
||||
5. **Strengthen TOCTOU stale protection for equal timestamps.**
|
||||
Why this is better: Prevents regressions when `updated_at` is equal but a fresher local fetch already happened.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
-13. ... If local `updated_at` is newer than preflight payload `updated_at`, skip ...
|
||||
+13. ... Skip stale when:
|
||||
+ a) local.updated_at > payload.updated_at, OR
|
||||
+ b) local.updated_at == payload.updated_at AND local.last_seen_at > preflight_started_at_ms.
|
||||
+ This prevents equal-timestamp regressions under concurrent sync.
|
||||
@@ Step 1f tests
|
||||
+Add test: `equal_updated_at_but_newer_last_seen_is_skipped`.
|
||||
```
|
||||
|
||||
6. **Shrink lock window further: release `sync` lock before embed; use dedicated embed lock.**
|
||||
Why this is better: Prevents long embedding from blocking unrelated syncs and avoids concurrent embed writers.
|
||||
|
||||
```diff
|
||||
@@ Design Constraints
|
||||
-11. Lock ... held through all mutation stages.
|
||||
+11. Lock ... held through ingest/dependents/docs only.
|
||||
+ Release `AppLock("sync")` before embed.
|
||||
+ Embed stage uses `AppLock("embed")` for single-flight embedding writes.
|
||||
@@ Step 9
|
||||
-Embed runs inside the same sync lock window
|
||||
+Embed runs after sync lock release, under dedicated embed lock
|
||||
```
|
||||
|
||||
7. **Add the missing `sync-runs` robot read path (the plan references it but doesn’t define it).**
|
||||
Why this is better: Makes durable run-state actually useful for recovery automation and observability.
|
||||
|
||||
```diff
|
||||
@@ Step 14 (new)
|
||||
+## Step 14a: Add `sync-runs` read command
|
||||
+
|
||||
+CLI:
|
||||
+ lore --robot sync-runs --limit 20
|
||||
+ lore --robot sync-runs --run-id <id>
|
||||
+ lore --robot sync-runs --state failed
|
||||
+
|
||||
+Robot response fields:
|
||||
+ run_id, mode, phase, status, started_at, finished_at, counters, failures, suggested_retry_command
|
||||
```
|
||||
|
||||
8. **Add URL-native surgical targets (`--issue-url`, `--mr-url`) with project inference.**
|
||||
Why this is better: Much more agent-friendly and reduces project-resolution errors from copy/paste workflows.
|
||||
|
||||
```diff
|
||||
@@ CLI Interface
|
||||
lore sync --issue 123 --issue 456 -p myproject
|
||||
+lore sync --issue-url https://gitlab.example.com/group/proj/-/issues/123
|
||||
+lore sync --mr-url https://gitlab.example.com/group/proj/-/merge_requests/789
|
||||
@@ Step 2
|
||||
+Add repeatable flags:
|
||||
+ --issue-url <url>
|
||||
+ --mr-url <url>
|
||||
+Parse URL into (project_path, iid). If all targets are URL-derived and same project, `-p` is optional.
|
||||
+If mixed projects are provided in one command, reject with clear error.
|
||||
```
|
||||
|
||||
If you want, I can produce a single consolidated patched version of your plan (iteration 5 draft) with these revisions already merged.
|
||||
152
docs/plan-surgical-sync.feedback-6.md
Normal file
152
docs/plan-surgical-sync.feedback-6.md
Normal file
@@ -0,0 +1,152 @@
|
||||
Highest-impact revisions after reviewing your v5 plan:
|
||||
|
||||
1. **Fix a real scoping hole: embed can still process unrelated docs**
|
||||
Rationale: Current plan assumes scoped docs implies scoped embed, but that only holds while no other run creates unembedded docs. You explicitly release sync lock before embed, so another sync can enqueue/regenerate docs in between, and `run_embed` may embed unrelated backlog. This breaks surgical isolation and can hide backlog debt.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Design Constraints
|
||||
-3. Embed scoping: Embedding runs only for documents regenerated by this surgical run. Because `run_embed` processes only unembedded docs, scoping is automatic IF docs are scoped correctly...
|
||||
+3. Embed scoping: Embedding MUST be explicitly scoped to documents regenerated by this surgical run.
|
||||
+ `run_generate_docs_for_sources` returns regenerated `document_ids`; surgical mode calls
|
||||
+ `run_embed_for_document_ids(document_ids)` and never global `run_embed`.
|
||||
+ This remains true even after lock release and under concurrent normal sync activity.
|
||||
@@ Step 9a: Implement `run_generate_docs_for_sources`
|
||||
-pub fn run_generate_docs_for_sources(...) -> Result<GenerateDocsResult> {
|
||||
+pub fn run_generate_docs_for_sources(...) -> Result<GenerateDocsResult> {
|
||||
+ // Return regenerated document IDs for scoped embedding.
|
||||
+ // GenerateDocsResult { regenerated, errored, regenerated_document_ids: Vec<i64> }
|
||||
@@ Step 9: Embed stage
|
||||
- match run_embed(config, false, false, None, signal).await {
|
||||
+ match run_embed_for_document_ids(config, &result.regenerated_document_ids, signal).await {
|
||||
```
|
||||
|
||||
2. **Make run-ledger lifecycle actually durable (and consistent with your own constraint 10)**
|
||||
Rationale: Plan text says “reuse `SyncRunRecorder`”, but Step 9 writes raw SQL directly. That creates lifecycle drift, missing heartbeats, and inconsistent failure handling as code evolves.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Design Constraints
|
||||
-10. Durable run state: ... Reuses `SyncRunRecorder` row lifecycle ...
|
||||
+10. Durable run state: surgical sync MUST use `SyncRunRecorder` end-to-end (no ad-hoc SQL updates).
|
||||
+ Add recorder APIs for `set_mode`, `set_phase`, `set_counters`, `finish_succeeded`,
|
||||
+ `finish_failed`, `finish_cancelled`, and periodic `heartbeat`.
|
||||
@@ Step 9: Create `run_sync_surgical`
|
||||
- conn.execute("INSERT INTO sync_runs ...")
|
||||
- conn.execute("UPDATE sync_runs SET phase = ...")
|
||||
+ let mut recorder = SyncRunRecorder::start_surgical(...)?;
|
||||
+ recorder.set_phase("preflight")?;
|
||||
+ recorder.heartbeat_if_due()?;
|
||||
+ recorder.set_phase("ingest")?;
|
||||
+ ...
|
||||
+ recorder.finish_succeeded_with_warnings(...)?;
|
||||
```
|
||||
|
||||
3. **Add explicit `cancelled` terminal state**
|
||||
Rationale: Current early cancellation branches return `Ok(result)` without guaranteed run-row finalization. That leaves misleading `running` rows and weak crash diagnostics.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Design Constraints
|
||||
+15. Cancellation semantics: If shutdown is observed after run start, phase is set to `cancelled`,
|
||||
+ status is `cancelled`, `finished_at` is written, and lock is released before return.
|
||||
@@ Step 8a migration
|
||||
+ALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;
|
||||
+ALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;
|
||||
@@ Acceptance Criteria
|
||||
+47. Cancellation durability: Ctrl+C during surgical sync records `status='cancelled'`,
|
||||
+ `phase='cancelled'`, and `finished_at` in `sync_runs`.
|
||||
```
|
||||
|
||||
4. **Reduce lock contention further by separating dependent fetch and dependent write**
|
||||
Rationale: You currently hold lock through network-heavy dependent stages. That maximizes contention and increases lock timeout risk. Better: fetch dependents unlocked, write in short locked transactions with per-entity freshness guards.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Design Constraints
|
||||
-11. Lock window minimization: ... held through ingest, dependents, and docs stages.
|
||||
+11. Lock window minimization: lock is held only for DB mutation windows.
|
||||
+ Dependents run in two phases:
|
||||
+ (a) fetch from GitLab without lock,
|
||||
+ (b) write results under lock in short transactions.
|
||||
+ Apply per-entity freshness checks before dependent writes.
|
||||
@@ Step 9: Dependent stages
|
||||
- // All dependents run INLINE per-entity ... while lock is held
|
||||
+ // Dependents fetch outside lock, then write under lock with CAS-style watermark guards.
|
||||
```
|
||||
|
||||
5. **Introduce stage timeout budgets to prevent hung surgical runs**
|
||||
Rationale: A single slow GitLab endpoint can stall the whole run and hold resources too long. Timeout budgets plus per-entity failure recording keep the run bounded and predictable.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Design Constraints
|
||||
+16. Stage timeout budgets: each dependent fetch has a per-entity timeout and a global stage budget.
|
||||
+ Timed-out entities are recorded in `entity_failures` with code `TIMEOUT` and run continues best-effort.
|
||||
@@ Step 9 notes
|
||||
+ - Wrap dependent network calls with `tokio::time::timeout`.
|
||||
+ - Add config knobs:
|
||||
+ `sync.surgical_entity_timeout_seconds` (default 20),
|
||||
+ `sync.surgical_dependents_budget_seconds` (default 120).
|
||||
```
|
||||
|
||||
6. **Add payload integrity checks (project mismatch hard-fail)**
|
||||
Rationale: Surgical mode is precision tooling. If API/proxy misconfiguration returns payloads from wrong project, you should fail preflight loudly, not trust downstream assumptions.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Step 7: preflight_fetch
|
||||
+ // Integrity check: payload.project_id must equal requested gitlab_project_id.
|
||||
+ // On mismatch, record EntityFailure { code: "PROJECT_MISMATCH", stage: "fetch" }.
|
||||
@@ Step 9d: error codes
|
||||
+PROJECT_MISMATCH -> usage/config data integrity failure (typed, machine-readable)
|
||||
@@ Acceptance Criteria
|
||||
+48. Project integrity: payloads with unexpected `project_id` are rejected in preflight
|
||||
+ and produce zero content writes.
|
||||
```
|
||||
|
||||
7. **Upgrade robot output from aggregate-only to per-entity lifecycle**
|
||||
Rationale: `entity_failures` alone is not enough for robust automation. Agents need a complete entity outcome map (fetched, ingested, stale-skipped, dependent failures) to retry deterministically.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Step 15: Update `SyncResult`
|
||||
+pub struct EntityOutcome {
|
||||
+ pub entity_type: String,
|
||||
+ pub iid: u64,
|
||||
+ pub fetched: bool,
|
||||
+ pub ingested: bool,
|
||||
+ pub stale_skipped: bool,
|
||||
+ pub dependent_failures: Vec<EntityFailure>,
|
||||
+}
|
||||
@@
|
||||
+pub entity_outcomes: Vec<EntityOutcome>,
|
||||
+pub completion_status: String, // succeeded | succeeded_with_warnings | failed | cancelled
|
||||
@@ Robot mode
|
||||
- enables agents to detect partial failures via `entity_failures`
|
||||
+ enables deterministic, per-IID retry and richer UI messaging.
|
||||
```
|
||||
|
||||
8. **Index `sync_runs` for real observability at scale**
|
||||
Rationale: You’re adding mode/phase/counters and then querying recent surgical runs. Without indexes, this degrades as run history grows.
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Step 8a migration
|
||||
+CREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started
|
||||
+ ON sync_runs(mode, started_at DESC);
|
||||
+CREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started
|
||||
+ ON sync_runs(status, phase, started_at DESC);
|
||||
```
|
||||
|
||||
9. **Add tests specifically for the new failure-prone paths**
|
||||
Rationale: Current tests are strong on ingest and scoping, but still miss new high-risk runtime behavior (cancel state, timeout handling, scoped embed under concurrency).
|
||||
```diff
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@ Step 1f tests
|
||||
+#[tokio::test]
|
||||
+async fn cancellation_marks_sync_run_cancelled() { ... }
|
||||
+
|
||||
+#[tokio::test]
|
||||
+async fn dependent_timeout_records_entity_failure_and_continues() { ... }
|
||||
+
|
||||
+#[tokio::test]
|
||||
+async fn scoped_embed_does_not_embed_unrelated_docs_created_after_docs_stage() { ... }
|
||||
@@ Acceptance Criteria
|
||||
+49. Scoped embed isolation under concurrency is verified by automated test.
|
||||
+50. Timeout path is verified (TIMEOUT code + continued processing).
|
||||
```
|
||||
|
||||
These revisions keep your core direction intact, avoid every rejected recommendation, and materially improve correctness under concurrency, operational observability, and agent automation quality.
|
||||
2240
docs/plan-surgical-sync.md
Normal file
2240
docs/plan-surgical-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,174 +0,0 @@
|
||||
Highest-impact gaps I see in the current plan:
|
||||
|
||||
1. `for-issue` / `for-mr` filtering is ambiguous across projects and can return incorrect rows.
|
||||
2. `lore notes` has no pagination contract, so large exports and deterministic resumption are weak.
|
||||
3. Migration `022` is high-risk (table rebuild + FTS + junction tables) without explicit integrity gates.
|
||||
4. Note-doc freshness is incomplete for upstream note deletions and parent metadata changes (labels/title).
|
||||
|
||||
Below are my best revisions, each with rationale and a git-diff-style plan edit.
|
||||
|
||||
---
|
||||
|
||||
1. **Add gated rollout + rollback controls**
|
||||
Rationale: You can still “ship together” while reducing blast radius. This makes recovery fast if note-doc generation causes DB/embedding pressure.
|
||||
|
||||
```diff
|
||||
@@ ## Design
|
||||
-Two phases, shipped together as one feature:
|
||||
+Two phases, shipped together as one feature, but with runtime gates:
|
||||
+
|
||||
+- `feature.notes_cli` (Phase 1 surface)
|
||||
+- `feature.note_documents` (Phase 2 indexing/extraction path)
|
||||
+
|
||||
+Rollout order:
|
||||
+1) Enable `notes_cli`
|
||||
+2) Run note-doc backfill in bounded batches
|
||||
+3) Enable `note_documents` for continuous updates
|
||||
+
|
||||
+Rollback:
|
||||
+- Disabling `feature.note_documents` stops new note-doc generation without affecting issue/MR/discussion docs.
|
||||
```
|
||||
|
||||
2. **Add keyset pagination + deterministic ordering**
|
||||
Rationale: Needed for year-long reviewer analysis and reliable “continue where I left off” behavior under concurrent updates.
|
||||
|
||||
```diff
|
||||
@@ pub struct NoteListFilters<'a> {
|
||||
pub limit: usize,
|
||||
+ pub cursor: Option<&'a str>, // keyset token "<sort_ms>:<id>"
|
||||
+ pub include_total_count: bool, // avoid COUNT(*) in hot paths
|
||||
@@
|
||||
- pub sort: &'a str, // "created" (default) | "updated"
|
||||
+ pub sort: &'a str, // "created" | "updated"
|
||||
@@ query_notes SQL
|
||||
-ORDER BY {sort_column} {order}
|
||||
+ORDER BY {sort_column} {order}, n.id {order}
|
||||
LIMIT ?
|
||||
```
|
||||
|
||||
3. **Make `for-issue` / `for-mr` project-scoped**
|
||||
Rationale: IIDs are not globally unique. Requiring project avoids false positives and hard-to-debug cross-project leakage.
|
||||
|
||||
```diff
|
||||
@@ pub struct NotesArgs {
|
||||
- #[arg(long = "for-issue", help_heading = "Filters", conflicts_with = "for_mr")]
|
||||
+ #[arg(long = "for-issue", help_heading = "Filters", conflicts_with = "for_mr", requires = "project")]
|
||||
pub for_issue: Option<i64>,
|
||||
@@
|
||||
- #[arg(long = "for-mr", help_heading = "Filters", conflicts_with = "for_issue")]
|
||||
+ #[arg(long = "for-mr", help_heading = "Filters", conflicts_with = "for_issue", requires = "project")]
|
||||
pub for_mr: Option<i64>,
|
||||
```
|
||||
|
||||
4. **Upgrade path filtering semantics**
|
||||
Rationale: Review comments often reference renames/moves. Restricting to `position_new_path` misses relevant notes.
|
||||
|
||||
```diff
|
||||
@@ pub struct NotesArgs {
|
||||
- /// Filter by file path (trailing / for prefix match)
|
||||
+ /// Filter by file path
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub path: Option<String>,
|
||||
+ /// Path mode: exact|prefix|glob
|
||||
+ #[arg(long = "path-mode", value_parser = ["exact","prefix","glob"], default_value = "exact", help_heading = "Filters")]
|
||||
+ pub path_mode: String,
|
||||
+ /// Match against old path as well as new path
|
||||
+ #[arg(long = "match-old-path", help_heading = "Filters")]
|
||||
+ pub match_old_path: bool,
|
||||
@@ query_notes filter mappings
|
||||
-- `path` ... n.position_new_path ...
|
||||
+- `path` applies to `n.position_new_path` and optionally `n.position_old_path`.
|
||||
+- `glob` mode translates `*`/`?` to SQL LIKE with escaping.
|
||||
```
|
||||
|
||||
5. **Add explicit performance indexes (new migration)**
|
||||
Rationale: `notes` becomes a first-class query surface; without indexes, filters degrade quickly at 10k+ note scale.
|
||||
|
||||
```diff
|
||||
@@ ## Phase 1: `lore notes` Command
|
||||
+### Work Chunk 1E: Query Performance Indexes
|
||||
+**Files:** `migrations/023_notes_query_indexes.sql`, `src/core/db.rs`
|
||||
+
|
||||
+Add indexes:
|
||||
+- `notes(project_id, created_at DESC, id DESC)`
|
||||
+- `notes(author_username, created_at DESC, id DESC) WHERE is_system = 0`
|
||||
+- `notes(discussion_id)`
|
||||
+- `notes(position_new_path)`
|
||||
+- `notes(position_old_path)`
|
||||
+- `discussions(issue_id)`
|
||||
+- `discussions(merge_request_id)`
|
||||
```
|
||||
|
||||
6. **Harden migration 022 with transactional integrity checks**
|
||||
Rationale: This is the riskiest part of the plan. Add hard fail-fast checks so corruption cannot silently pass.
|
||||
|
||||
```diff
|
||||
@@ ### Work Chunk 2A: Schema Migration (022)
|
||||
+Migration safety requirements:
|
||||
+- Execute in a single `BEGIN IMMEDIATE ... COMMIT` transaction.
|
||||
+- Capture and compare pre/post row counts for `documents`, `document_labels`, `document_paths`, `dirty_sources`.
|
||||
+- Run `PRAGMA foreign_key_check` and abort on any violation.
|
||||
+- Run `PRAGMA integrity_check` and abort on non-`ok`.
|
||||
+- Rebuild FTS and assert `documents_fts` rowcount equals `documents` rowcount.
|
||||
```
|
||||
|
||||
7. **Add note deletion + parent-change propagation**
|
||||
Rationale: Current plan handles create/update ingestion but not all staleness paths. Without this, note documents drift.
|
||||
|
||||
```diff
|
||||
@@ ## Phase 2: Per-Note Documents
|
||||
+### Work Chunk 2G: Freshness Propagation
|
||||
+**Files:** `src/ingestion/discussions.rs`, `src/ingestion/mr_discussions.rs`, `src/documents/regenerator.rs`
|
||||
+
|
||||
+Rules:
|
||||
+- If a previously stored note is missing from upstream payload, delete local note row and enqueue `(note, id)` for document deletion.
|
||||
+- When parent issue/MR title or labels change, enqueue descendant note docs dirty (notes inherit parent metadata).
|
||||
+- Keep idempotent behavior for repeated syncs.
|
||||
```
|
||||
|
||||
8. **Separate FTS coverage from embedding coverage**
|
||||
Rationale: Biggest cost/perf risk is embeddings. Index all notes in FTS, but embed selectively with policy knobs.
|
||||
|
||||
```diff
|
||||
@@ ## Estimated Document Volume Impact
|
||||
-FTS5 handles this comfortably. Embedding generation time scales linearly (~4x increase).
|
||||
+FTS5 handles this comfortably. Embedding generation is policy-controlled:
|
||||
+- FTS: index all non-system note docs
|
||||
+- Embeddings default: only notes with body length >= 40 chars (configurable)
|
||||
+- Add config: `documents.note_embeddings.min_chars`, `documents.note_embeddings.enabled`
|
||||
+- Prioritize unresolved DiffNotes before other notes during embedding backfill
|
||||
```
|
||||
|
||||
9. **Bring structured reviewer profiling into scope (not narrative reporting)**
|
||||
Rationale: This directly serves the stated use case and makes the feature compelling immediately.
|
||||
|
||||
```diff
|
||||
@@ ## Non-Goals
|
||||
-- Adding a "reviewer profile" report command (that's a downstream use case built on this infrastructure)
|
||||
+- Generating free-form narrative reviewer reports.
|
||||
+ A structured profiling command is in scope.
|
||||
+
|
||||
+## Phase 3: Structured Reviewer Profiling
|
||||
+Add `lore notes profile --author <user> --since <window>` returning:
|
||||
+- top commented paths
|
||||
+- top parent labels
|
||||
+- unresolved-comment ratio
|
||||
+- note-type distribution
|
||||
+- median comment length
|
||||
```
|
||||
|
||||
10. **Add operational SLOs + robot-mode status for note pipeline**
|
||||
Rationale: Reliability improves when regressions are observable, not inferred from failures.
|
||||
|
||||
```diff
|
||||
@@ ## Verification Checklist
|
||||
+Operational checks:
|
||||
+- `lore -J stats` includes per-`source_type` document counts (including `note`)
|
||||
+- Add queue lag metrics: oldest dirty note age, retry backlog size
|
||||
+- Add extraction error breakdown by `source_type`
|
||||
+- Add smoke assertion: disabling `feature.note_documents` leaves other source regeneration unaffected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you want, I can produce a single consolidated revised PRD draft (fully merged text, not just diffs) as the next step.
|
||||
@@ -1,200 +0,0 @@
|
||||
Below are the strongest revisions I’d make, excluding everything in your `## Rejected Recommendations` list.
|
||||
|
||||
1. **Add a Phase 0 for stable note identity before any note-doc generation**
|
||||
Rationale: your current plan still allows note document churn because Issue discussion ingestion is delete/reinsert-based. That makes local `notes.id` unstable, causing unnecessary dirtying/regeneration and potential stale-doc edge cases. Stabilizing identity first (upsert-by-GitLab-ID + sweep stale) improves correctness and cuts repeated work.
|
||||
|
||||
```diff
|
||||
@@ ## Design
|
||||
-Two phases, shipped together as one feature:
|
||||
+Three phases, shipped together as one feature:
|
||||
+- **Phase 0 (Foundation):** Stable note identity in local DB (upsert + sweep, no delete/reinsert churn)
|
||||
- **Phase 1 (Option A):** `lore notes` command — direct SQL query over the `notes` table with rich filtering
|
||||
- **Phase 2 (Option B):** Per-note documents — each non-system note becomes its own searchable document in the FTS/embedding pipeline
|
||||
@@
|
||||
+## Phase 0: Stable Note Identity
|
||||
+
|
||||
+### Work Chunk 0A: Upsert/Sweep for Issue Discussion Notes
|
||||
+**Files:** `src/ingestion/discussions.rs`, `migrations/022_notes_identity_index.sql`, `src/core/db.rs`
|
||||
+**Implementation:**
|
||||
+- Add unique index: `UNIQUE(project_id, gitlab_id)` on `notes`
|
||||
+- Replace delete/reinsert issue-note flow with upsert + `last_seen_at` sweep (same durability model as MR note sweep)
|
||||
+- Ensure `insert_note/upsert_note` returns the stable local row id for both insert and update paths
|
||||
```
|
||||
|
||||
2. **Replace `source_type` CHECK constraints with a registry table + FK in migration**
|
||||
Rationale: table CHECKs force full table rebuild for every new source type forever. A `source_types` table with FK keeps DB-level integrity and future extensibility without rebuilding `documents`/`dirty_sources` every time. This is a major architecture hardening win.
|
||||
|
||||
```diff
|
||||
@@ ### Work Chunk 2A: Schema Migration (023)
|
||||
-Current migration ... CHECK constraints limiting `source_type` ...
|
||||
+Current migration ... CHECK constraints limiting `source_type` ...
|
||||
+Revision: migrate to `source_types` registry table + FK constraints.
|
||||
@@
|
||||
-1. `dirty_sources` — add `'note'` to source_type CHECK
|
||||
-2. `documents` — add `'note'` to source_type CHECK
|
||||
+1. Create `source_types(name TEXT PRIMARY KEY)` and seed: `issue, merge_request, discussion, note`
|
||||
+2. Rebuild `dirty_sources` and `documents` to replace CHECK with `REFERENCES source_types(name)`
|
||||
+3. Future source-type additions become `INSERT INTO source_types(name) VALUES (?)` (no table rebuild)
|
||||
@@
|
||||
+#### Additional integrity tests
|
||||
+#[test]
|
||||
+fn test_source_types_registry_contains_note() { ... }
|
||||
+#[test]
|
||||
+fn test_documents_source_type_fk_enforced() { ... }
|
||||
+#[test]
|
||||
+fn test_dirty_sources_source_type_fk_enforced() { ... }
|
||||
```
|
||||
|
||||
3. **Mark note documents dirty only when note semantics actually changed**
|
||||
Rationale: current loops mark every non-system note dirty every sync. With 8k+ notes this creates avoidable queue pressure and regeneration time. Change-aware dirtying (inserted/changed only) gives major performance and stability improvements.
|
||||
|
||||
```diff
|
||||
@@ ### Work Chunk 2D: Regenerator & Dirty Tracking Integration
|
||||
-for note in notes {
|
||||
- let local_note_id = insert_note(&tx, local_discussion_id, ¬e, None)?;
|
||||
- if !note.is_system {
|
||||
- dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, local_note_id)?;
|
||||
- }
|
||||
-}
|
||||
+for note in notes {
|
||||
+ let outcome = upsert_note(&tx, local_discussion_id, ¬e, None)?;
|
||||
+ if !note.is_system && outcome.changed_semantics {
|
||||
+ dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;
|
||||
+ }
|
||||
+}
|
||||
@@
|
||||
+// changed_semantics should include: body, note_type, path/line positions, resolvable/resolved/resolved_by, updated_at
|
||||
```
|
||||
|
||||
4. **Expand filters to support real analysis windows and resolution state**
|
||||
Rationale: reviewer profiling usually needs bounded windows and both resolved/unresolved views. Current `unresolved: bool` is too narrow and one-sided. Add `--until` and tri-state resolution filtering for better analytical power.
|
||||
|
||||
```diff
|
||||
@@ pub struct NoteListFilters<'a> {
|
||||
- pub since: Option<&'a str>,
|
||||
+ pub since: Option<&'a str>,
|
||||
+ pub until: Option<&'a str>,
|
||||
@@
|
||||
- pub unresolved: bool,
|
||||
+ pub resolution: &'a str, // "any" (default) | "unresolved" | "resolved"
|
||||
@@
|
||||
- pub author: Option<&'a str>,
|
||||
+ pub author: Option<&'a str>, // case-insensitive match
|
||||
@@
|
||||
- // Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||
+ // Filter by start time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||
pub since: Option<String>,
|
||||
+ /// Filter by end time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||
+ #[arg(long, help_heading = "Filters")]
|
||||
+ pub until: Option<String>,
|
||||
@@
|
||||
- /// Only show unresolved review comments
|
||||
- pub unresolved: bool,
|
||||
+ /// Resolution filter: any, unresolved, resolved
|
||||
+ #[arg(long, value_parser = ["any", "unresolved", "resolved"], default_value = "any", help_heading = "Filters")]
|
||||
+ pub resolution: String,
|
||||
```
|
||||
|
||||
5. **Broaden index strategy to match actual query shapes, not just author queries**
|
||||
Rationale: `idx_notes_user_created` helps one path, but common usage also includes project+time scans and unresolved filters. Add two more partial composites for high-selectivity paths.
|
||||
|
||||
```diff
|
||||
@@ ### Work Chunk 1E: Composite Query Index
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user_created
|
||||
ON notes(project_id, author_username, created_at DESC, id DESC)
|
||||
WHERE is_system = 0;
|
||||
+
|
||||
+CREATE INDEX IF NOT EXISTS idx_notes_project_created
|
||||
+ON notes(project_id, created_at DESC, id DESC)
|
||||
+WHERE is_system = 0;
|
||||
+
|
||||
+CREATE INDEX IF NOT EXISTS idx_notes_unresolved_project_created
|
||||
+ON notes(project_id, created_at DESC, id DESC)
|
||||
+WHERE is_system = 0 AND resolvable = 1 AND resolved = 0;
|
||||
@@
|
||||
+#[test]
|
||||
+fn test_notes_query_plan_uses_project_created_index_for_default_listing() { ... }
|
||||
+#[test]
|
||||
+fn test_notes_query_plan_uses_unresolved_index_when_resolution_unresolved() { ... }
|
||||
```
|
||||
|
||||
6. **Improve per-note document payload with structured metadata header + minimal thread context**
|
||||
Rationale: isolated single-note docs can lose meaning. A small structured header plus lightweight context (parent + one preceding note excerpt) improves semantic retrieval quality substantially without re-bundling full threads.
|
||||
|
||||
```diff
|
||||
@@ ### Work Chunk 2C: Note Document Extractor
|
||||
-// 6. Format content:
|
||||
-// [[Note]] {note_type or "Comment"} on {parent_type_prefix}: {parent_title}
|
||||
-// Project: {path_with_namespace}
|
||||
-// URL: {url}
|
||||
-// Author: @{author}
|
||||
-// Date: {format_date(created_at)}
|
||||
-// Labels: {labels_json}
|
||||
-// File: {position_new_path}:{position_new_line} (if DiffNote)
|
||||
-//
|
||||
-// --- Body ---
|
||||
-//
|
||||
-// {body}
|
||||
+// 6. Format content with machine-readable header:
|
||||
+// [[Note]]
|
||||
+// source_type: note
|
||||
+// note_gitlab_id: {gitlab_id}
|
||||
+// project: {path_with_namespace}
|
||||
+// parent_type: {Issue|MergeRequest}
|
||||
+// parent_iid: {iid}
|
||||
+// note_type: {DiffNote|DiscussionNote|Comment}
|
||||
+// author: @{author}
|
||||
+// created_at: {iso8601}
|
||||
+// resolved: {true|false}
|
||||
+// path: {position_new_path}:{position_new_line}
|
||||
+// url: {url}
|
||||
+//
|
||||
+// --- Context ---
|
||||
+// parent_title: {title}
|
||||
+// previous_note_excerpt: {optional, max 200 chars}
|
||||
+//
|
||||
+// --- Body ---
|
||||
+// {body}
|
||||
```
|
||||
|
||||
7. **Add first-class export modes for downstream profiling pipelines**
|
||||
Rationale: this makes the feature much more useful immediately (LLM prompts, notebook analysis, external scripts) without adding a profiling command. It stays within your non-goals and increases adoption.
|
||||
|
||||
```diff
|
||||
@@ pub struct NotesArgs {
|
||||
+ /// Output format
|
||||
+ #[arg(long, value_parser = ["table", "json", "jsonl", "csv"], default_value = "table", help_heading = "Output")]
|
||||
+ pub format: String,
|
||||
@@
|
||||
- if robot_mode {
|
||||
+ if robot_mode || args.format == "json" || args.format == "jsonl" || args.format == "csv" {
|
||||
print_list_notes_json(...)
|
||||
} else {
|
||||
print_list_notes(&result);
|
||||
}
|
||||
@@ ### Work Chunk 1C: Human & Robot Output Formatting
|
||||
+Add `print_list_notes_csv()` and `print_list_notes_jsonl()`:
|
||||
+- CSV columns mirror `NoteListRowJson` field names
|
||||
+- JSONL emits one note object per line for streaming pipelines
|
||||
```
|
||||
|
||||
8. **Strengthen verification with idempotence + migration data-preservation checks**
|
||||
Rationale: this feature touches ingestion, migrations, indexing, and regeneration. Add explicit idempotence/perf checks so regressions surface early.
|
||||
|
||||
```diff
|
||||
@@ ## Verification Checklist
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
+cargo test test_note_ingestion_idempotent_across_two_syncs
|
||||
+cargo test test_note_document_count_stable_after_second_generate_docs_full
|
||||
@@
|
||||
+lore sync
|
||||
+lore generate-docs --full
|
||||
+lore -J stats > /tmp/stats1.json
|
||||
+lore generate-docs --full
|
||||
+lore -J stats > /tmp/stats2.json
|
||||
+# assert note doc count unchanged and dirty queue drains to zero
|
||||
```
|
||||
|
||||
If you want, I can turn this into a fully rewritten PRD v2 draft with these changes merged in-place and renumbered work chunks end-to-end.
|
||||
@@ -1,162 +0,0 @@
|
||||
These are the highest-impact revisions I’d make. They avoid everything in your `## Rejected Recommendations` list.
|
||||
|
||||
1. Add immediate note-document deletion propagation (don’t wait for `generate-docs --full`)
|
||||
Why: right now, deleted notes can leave stale `source_type='note'` documents until a full rebuild. That creates incorrect search/reporting results and weakens trust in the dataset.
|
||||
```diff
|
||||
@@ Phase 0: Stable Note Identity
|
||||
+### Work Chunk 0B: Immediate Deletion Propagation
|
||||
+
|
||||
+When sweep deletes stale notes, propagate deletion to documents in the same transaction.
|
||||
+Do not rely on eventual cleanup via `generate-docs --full`.
|
||||
+
|
||||
+#### Tests to Write First
|
||||
+#[test]
|
||||
+fn test_issue_note_sweep_deletes_note_documents_immediately() { ... }
|
||||
+#[test]
|
||||
+fn test_mr_note_sweep_deletes_note_documents_immediately() { ... }
|
||||
+
|
||||
+#### Implementation
|
||||
+Use `DELETE ... RETURNING id, is_system` in note sweep functions.
|
||||
+For returned non-system note ids:
|
||||
+1) `DELETE FROM documents WHERE source_type='note' AND source_id=?`
|
||||
+2) `DELETE FROM dirty_sources WHERE source_type='note' AND source_id=?`
|
||||
```
|
||||
|
||||
2. Add one-time upgrade backfill for existing notes (migration 024)
|
||||
Why: existing DBs will otherwise only get note-documents for changed/new notes. Historical notes remain invisible unless users manually run full rebuild.
|
||||
```diff
|
||||
@@ Phase 2: Per-Note Documents
|
||||
+### Work Chunk 2H: Backfill Existing Notes After Upgrade (Migration 024)
|
||||
+
|
||||
+Create migration `024_note_dirty_backfill.sql`:
|
||||
+INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
+SELECT 'note', n.id, unixepoch('now') * 1000
|
||||
+FROM notes n
|
||||
+LEFT JOIN documents d
|
||||
+ ON d.source_type='note' AND d.source_id=n.id
|
||||
+WHERE n.is_system=0 AND d.id IS NULL
|
||||
+ON CONFLICT(source_type, source_id) DO NOTHING;
|
||||
+
|
||||
+Add migration test asserting idempotence and expected queue size.
|
||||
```
|
||||
|
||||
3. Fix `--since/--until` semantics and validation
|
||||
Why: reusing `parse_since` for `until` creates ambiguous windows and off-by-boundary behavior; your own example `--since 90d --until 180d` is chronologically reversed.
|
||||
```diff
|
||||
@@ Work Chunk 1A: Data Types & Query Layer
|
||||
- since: parse_since(since_str) then n.created_at >= ?
|
||||
- until: parse_since(until_str) then n.created_at <= ?
|
||||
+ since: parse_since_start_bound(since_str) then n.created_at >= ?
|
||||
+ until: parse_until_end_bound(until_str) then n.created_at <= ?
|
||||
+ Validate since <= until; otherwise return a clear user error.
|
||||
+
|
||||
+#### Tests to Write First
|
||||
+#[test] fn test_query_notes_invalid_time_window_rejected() { ... }
|
||||
+#[test] fn test_query_notes_until_date_is_end_of_day_inclusive() { ... }
|
||||
```
|
||||
|
||||
4. Separate semantic-change detection from housekeeping updates
|
||||
Why: current proposed `WHERE` includes `updated_at`, which will cause unnecessary dirty churn. You want `last_seen_at` to always refresh, but regeneration only when searchable semantics changed.
|
||||
```diff
|
||||
@@ Work Chunk 0A: Upsert/Sweep for Issue Discussion Notes
|
||||
- OR notes.updated_at IS NOT excluded.updated_at
|
||||
+ -- updated_at-only changes should not mark semantic dirty
|
||||
+
|
||||
+Perform two-step logic:
|
||||
+1) Upsert always updates persistence/housekeeping fields (`updated_at`, `last_seen_at`).
|
||||
+2) `changed_semantics` is computed only from fields used by note documents/search filters
|
||||
+ (body, note_type, resolved flags, paths, author, parent linkage).
|
||||
+
|
||||
+#### Tests to Write First
|
||||
+#[test]
|
||||
+fn test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change() { ... }
|
||||
```
|
||||
|
||||
5. Make indexes align with actual query collation and join strategy
|
||||
Why: `author` uses `COLLATE NOCASE`; without collation-aware index, SQLite can skip index use. Also, IID filters via scalar subqueries are harder for planner than direct join predicates.
|
||||
```diff
|
||||
@@ Work Chunk 1E: Composite Query Index
|
||||
-CREATE INDEX ... ON notes(project_id, author_username, created_at DESC, id DESC) WHERE is_system = 0;
|
||||
+CREATE INDEX ... ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC) WHERE is_system = 0;
|
||||
+
|
||||
+CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);
|
||||
+CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);
|
||||
```
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 1A: query_notes()
|
||||
- d.issue_id = (SELECT id FROM issues WHERE iid = ? AND project_id = ?)
|
||||
+ i.iid = ? AND i.project_id = ?
|
||||
- d.merge_request_id = (SELECT id FROM merge_requests WHERE iid = ? AND project_id = ?)
|
||||
+ m.iid = ? AND m.project_id = ?
|
||||
```
|
||||
|
||||
6. Replace manual CSV escaping with `csv` crate
|
||||
Why: manual RFC4180 escaping is fragile (quotes/newlines/multi-byte edge cases). This is exactly where a mature library reduces long-term bug risk.
|
||||
```diff
|
||||
@@ Work Chunk 1C: Human & Robot Output Formatting
|
||||
- Uses a minimal CSV writer (no external dependency — the format is simple enough for manual escaping).
|
||||
+ Uses `csv::Writer` for RFC4180-compliant escaping and stable output across edge cases.
|
||||
+
|
||||
+#### Tests to Write First
|
||||
+#[test] fn test_csv_output_multiline_and_quotes_roundtrip() { ... }
|
||||
```
|
||||
|
||||
7. Add `--contains` lexical body filter to `lore notes`
|
||||
Why: useful middle ground between metadata filtering and semantic search; great for reviewer-pattern mining without requiring FTS query syntax.
|
||||
```diff
|
||||
@@ Work Chunk 1B: CLI Arguments & Command Wiring
|
||||
+/// Filter by case-insensitive substring in note body
|
||||
+#[arg(long, help_heading = "Filters")]
|
||||
+pub contains: Option<String>;
|
||||
```
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 1A: NoteListFilters
|
||||
+ pub contains: Option<&'a str>,
|
||||
@@ query_notes dynamic filters
|
||||
+ if contains.is_some() {
|
||||
+ where_clauses.push("n.body LIKE ? COLLATE NOCASE");
|
||||
+ params.push(format!("%{}%", escape_like(contains.unwrap())));
|
||||
+ }
|
||||
```
|
||||
|
||||
8. Reduce note-document embedding noise by slimming metadata header
|
||||
Why: current verbose key-value header repeats low-signal tokens and consumes embedding budget. Keep context, but bias tokens toward actual review text.
|
||||
```diff
|
||||
@@ Work Chunk 2C: Note Document Extractor
|
||||
- Build content with structured metadata header:
|
||||
- [[Note]]
|
||||
- source_type: note
|
||||
- note_gitlab_id: ...
|
||||
- project: ...
|
||||
- ...
|
||||
- --- Body ---
|
||||
- {body}
|
||||
+ Build content with compact, high-signal layout:
|
||||
+ [[Note]]
|
||||
+ @{author} on {Issue#|MR!}{iid} in {project_path}
|
||||
+ path: {path:line} (only when available)
|
||||
+ state: {resolved|unresolved} (only when resolvable)
|
||||
+
|
||||
+ {body}
|
||||
+
|
||||
+Keep detailed metadata in structured document columns/labels/paths/url,
|
||||
+not repeated in verbose text.
|
||||
```
|
||||
|
||||
9. Add explicit performance regression checks for the new hot paths
|
||||
Why: this feature increases document volume ~4x; you should pin acceptable query behavior now so future changes don’t silently degrade.
|
||||
```diff
|
||||
@@ Verification Checklist
|
||||
+Performance/plan checks:
|
||||
+1) `EXPLAIN QUERY PLAN` for:
|
||||
+ - author+since query
|
||||
+ - project+date query
|
||||
+ - for-mr / for-issue query
|
||||
+2) Seed 50k-note synthetic fixture and assert:
|
||||
+ - `lore notes --author ... --limit 100` stays under agreed local threshold
|
||||
+ - `lore search --type note ...` remains deterministic and completes successfully
|
||||
```
|
||||
|
||||
If you want, I can also provide a fully merged “iteration 3” PRD text with these edits applied end-to-end so you can drop it in directly.
|
||||
@@ -1,187 +0,0 @@
|
||||
1. **Canonical note identity for documents: use `notes.gitlab_id` as `source_id`**
|
||||
Why this is better: the current plan still couples document identity to local row IDs. Even with upsert+sweep, local IDs are a storage artifact and can be reused in edge cases. Using GitLab note IDs as canonical document IDs makes regeneration, backfill, and deletion propagation more stable and portable.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Phase 0: Stable Note Identity
|
||||
-Phase 2 depends on `notes.id` as the `source_id` for note documents.
|
||||
+Phase 2 uses `notes.gitlab_id` as the `source_id` for note documents.
|
||||
+`notes.id` remains an internal relational key only.
|
||||
|
||||
@@ Work Chunk 0A
|
||||
pub struct NoteUpsertOutcome {
|
||||
pub local_note_id: i64,
|
||||
+ pub document_source_id: i64, // notes.gitlab_id
|
||||
pub changed_semantics: bool,
|
||||
}
|
||||
|
||||
@@ Work Chunk 2D
|
||||
-if !note.is_system && outcome.changed_semantics {
|
||||
- dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;
|
||||
+if !note.is_system && outcome.changed_semantics {
|
||||
+ dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.document_source_id)?;
|
||||
}
|
||||
|
||||
@@ Work Chunk 2E
|
||||
-SELECT 'note', n.id, ?1
|
||||
+SELECT 'note', n.gitlab_id, ?1
|
||||
|
||||
@@ Work Chunk 2H
|
||||
-ON d.source_type = 'note' AND d.source_id = n.id
|
||||
+ON d.source_type = 'note' AND d.source_id = n.gitlab_id
|
||||
```
|
||||
|
||||
2. **Prevent false deletions on partial/incomplete syncs**
|
||||
Why this is better: sweep-based deletion is correct only when a discussion’s notes were fully fetched. If a page fails mid-fetch, current logic can incorrectly delete valid notes. Add an explicit “fetch complete” guard before sweep.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Phase 0
|
||||
+### Work Chunk 0C: Sweep Safety Guard (Partial Fetch Protection)
|
||||
+
|
||||
+Only run stale-note sweep when note pagination completed successfully for that discussion.
|
||||
+If fetch is partial/interrupted, skip sweep and keep prior notes intact.
|
||||
|
||||
+#### Tests to Write First
|
||||
+#[test]
|
||||
+fn test_partial_fetch_does_not_sweep_notes() { /* ... */ }
|
||||
+
|
||||
+#[test]
|
||||
+fn test_complete_fetch_runs_sweep_notes() { /* ... */ }
|
||||
|
||||
+#### Implementation
|
||||
+if discussion_fetch_complete {
|
||||
+ sweep_stale_issue_notes(...)?;
|
||||
+} else {
|
||||
+ tracing::warn!("Skipping stale sweep for discussion {} due to partial fetch", discussion_gitlab_id);
|
||||
+}
|
||||
```
|
||||
|
||||
3. **Make deletion propagation set-based (not per-note loop)**
|
||||
Why this is better: the current per-note DELETE loop is O(N) statements and gets slow on large threads. A temp-table/CTE set-based delete is faster, simpler to reason about, and remains atomic.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Work Chunk 0B Implementation
|
||||
- for note_id in stale_note_ids {
|
||||
- conn.execute("DELETE FROM documents WHERE source_type = 'note' AND source_id = ?", [note_id])?;
|
||||
- conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note' AND source_id = ?", [note_id])?;
|
||||
- }
|
||||
+ CREATE TEMP TABLE _stale_note_source_ids(source_id INTEGER PRIMARY KEY) WITHOUT ROWID;
|
||||
+ INSERT INTO _stale_note_source_ids
|
||||
+ SELECT gitlab_id
|
||||
+ FROM notes
|
||||
+ WHERE discussion_id = ? AND last_seen_at < ? AND is_system = 0;
|
||||
+
|
||||
+ DELETE FROM notes
|
||||
+ WHERE discussion_id = ? AND last_seen_at < ?;
|
||||
+
|
||||
+ DELETE FROM documents
|
||||
+ WHERE source_type = 'note'
|
||||
+ AND source_id IN (SELECT source_id FROM _stale_note_source_ids);
|
||||
+
|
||||
+ DELETE FROM dirty_sources
|
||||
+ WHERE source_type = 'note'
|
||||
+ AND source_id IN (SELECT source_id FROM _stale_note_source_ids);
|
||||
+
|
||||
+ DROP TABLE _stale_note_source_ids;
|
||||
```
|
||||
|
||||
4. **Fix project-scoping and time-window semantics in `lore notes`**
|
||||
Why this is better: the plan currently has a contradiction: clap `requires = "project"` blocks use of `defaultProject`, while query layer says default fallback is allowed. Also, `since/until` parsing should use one shared “now” to avoid subtle drift and inverted windows.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Work Chunk 1B NotesArgs
|
||||
-#[arg(long = "for-issue", ..., requires = "project")]
|
||||
+#[arg(long = "for-issue", ...)]
|
||||
pub for_issue: Option<i64>;
|
||||
|
||||
-#[arg(long = "for-mr", ..., requires = "project")]
|
||||
+#[arg(long = "for-mr", ...)]
|
||||
pub for_mr: Option<i64>;
|
||||
|
||||
@@ Work Chunk 1A Query Notes
|
||||
-- `since`: `parse_since(since_str)` then `n.created_at >= ?`
|
||||
-- `until`: `parse_since(until_str)` then `n.created_at <= ?`
|
||||
+- Parse `since` and `until` with a single anchored `now_ms` captured once per command.
|
||||
+- If user supplies `YYYY-MM-DD` for `--until`, interpret as end-of-day (23:59:59.999 UTC).
|
||||
+- Validate `since <= until` after both parse with same anchor.
|
||||
```
|
||||
|
||||
5. **Add an analytics mode (not a profile command): `lore notes --aggregate`**
|
||||
Why this is better: this directly supports the stated use case (review patterns) without introducing the rejected “profile report” command. It keeps scope narrow and reuses existing filters.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Phase 1
|
||||
+### Work Chunk 1F: Aggregation Mode for Notes Listing
|
||||
+
|
||||
+Add optional aggregation on top of `lore notes`:
|
||||
+- `--aggregate author|note_type|path|resolution`
|
||||
+- `--top N` (default 20)
|
||||
+
|
||||
+Behavior:
|
||||
+- Reuses all existing filters (`--since`, `--project`, `--for-mr`, etc.)
|
||||
+- Returns grouped counts (+ percentage of filtered corpus)
|
||||
+- Works in table/json/jsonl/csv
|
||||
+
|
||||
+Non-goal alignment:
|
||||
+- This is not a narrative “reviewer profile” command.
|
||||
+- It is a query primitive for downstream analysis.
|
||||
```
|
||||
|
||||
6. **Prevent note backfill from starving other document regeneration**
|
||||
Why this is better: after migration/backfill, note dirty entries can dominate the queue and delay issue/MR/discussion updates. Add source-type fairness in regenerator scheduling.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Work Chunk 2D
|
||||
+#### Scheduling Revision
|
||||
+Process dirty sources with weighted fairness instead of strict FIFO:
|
||||
+- issue: 3
|
||||
+- merge_request: 3
|
||||
+- discussion: 2
|
||||
+- note: 1
|
||||
+
|
||||
+Implementation sketch:
|
||||
+- fetch next batch by source_type buckets
|
||||
+- interleave according to weights
|
||||
+- preserve retry semantics per source
|
||||
|
||||
+#### Tests to Write First
|
||||
+#[test]
|
||||
+fn test_note_backfill_does_not_starve_issue_and_mr_regeneration() { /* ... */ }
|
||||
```
|
||||
|
||||
7. **Harden migration 023: remove invalid SQL assertions and move integrity checks to tests**
|
||||
Why this is better: `RAISE(ABORT, ...)` in standalone `SELECT` is not valid SQLite usage outside triggers/check expressions. Keep migration SQL minimal/portable and enforce invariants in migration tests.
|
||||
|
||||
```diff
|
||||
--- a/PRD.md
|
||||
+++ b/PRD.md
|
||||
@@ Work Chunk 2A Migration SQL
|
||||
--- Step 10: Integrity verification
|
||||
-SELECT CASE
|
||||
- WHEN ... THEN RAISE(ABORT, '...')
|
||||
-END;
|
||||
+-- Step 10 removed from SQL migration.
|
||||
+-- Integrity verification is enforced in migration tests:
|
||||
+-- 1) pre/post row-count equality
|
||||
+-- 2) `PRAGMA foreign_key_check` is empty
|
||||
+-- 3) documents_fts row count matches documents row count after rebuild
|
||||
|
||||
@@ Work Chunk 2A Tests
|
||||
+#[test]
|
||||
+fn test_migration_023_integrity_checks_pass() {
|
||||
+ // pre/post counts, foreign_key_check empty, fts parity
|
||||
+}
|
||||
```
|
||||
|
||||
These 7 revisions improve correctness under failure, reduce churn risk, improve large-sync performance, and make the feature materially more useful for reviewer-analysis workflows without reintroducing any rejected recommendations.
|
||||
@@ -1,190 +0,0 @@
|
||||
Here are the highest-impact revisions I’d make. None of these repeat anything in your `## Rejected Recommendations`.
|
||||
|
||||
1. **Add immutable reviewer identity (`author_id`) as a first-class key**
|
||||
Why this improves the plan: the PRD’s core use case is year-scale reviewer profiling. Usernames are mutable in GitLab, so username-only filtering will fragment one reviewer into multiple identities over time. Adding `author_id` closes that correctness hole and makes historical analysis reliable.
|
||||
|
||||
```diff
|
||||
@@ Problem Statement
|
||||
-1. **Query individual notes by author** — the `--author` filter on `lore search` only matches the first note's author per discussion thread
|
||||
+1. **Query individual notes by reviewer identity** — support both mutable username and immutable GitLab `author_id` for stable longitudinal analysis
|
||||
|
||||
@@ Phase 0: Stable Note Identity
|
||||
+### Work Chunk 0D: Immutable Author Identity Capture
|
||||
+**Files:** `migrations/025_notes_author_id.sql`, `src/ingestion/discussions.rs`, `src/ingestion/mr_discussions.rs`, `src/cli/commands/list.rs`
|
||||
+
|
||||
+#### Implementation
|
||||
+- Add nullable `notes.author_id INTEGER` and backfill from future syncs.
|
||||
+- Populate `author_id` from GitLab note payload (`note.author.id`) on both issue and MR note ingestion paths.
|
||||
+- Add `--author-id <int>` filter to `lore notes`.
|
||||
+- Keep `--author` for ergonomics; when both provided, require both to match.
|
||||
+
|
||||
+#### Indexing
|
||||
+- Add `idx_notes_author_id_created ON notes(project_id, author_id, created_at DESC, id DESC) WHERE is_system = 0;`
|
||||
+
|
||||
+#### Tests
|
||||
+- `test_query_notes_filter_author_id_survives_username_change`
|
||||
+- `test_query_notes_author_and_author_id_intersection`
|
||||
```
|
||||
|
||||
2. **Strengthen partial-fetch safety from a boolean to an explicit fetch state contract**
|
||||
Why this improves the plan: `fetch_complete: bool` is easy to misuse and fragile under retries/crashes. A run-scoped state model makes sweep correctness auditable and prevents accidental deletions when ingestion aborts midway.
|
||||
|
||||
```diff
|
||||
@@ Phase 0: Stable Note Identity
|
||||
-### Work Chunk 0C: Sweep Safety Guard (Partial Fetch Protection)
|
||||
+### Work Chunk 0C: Sweep Safety Guard with Run-Scoped Fetch State
|
||||
|
||||
@@ Implementation
|
||||
-Add a `fetch_complete` parameter to the discussion ingestion functions. Only run the stale-note sweep when the fetch completed successfully:
|
||||
+Add a run-scoped fetch state:
|
||||
+- `FetchState::Complete`
|
||||
+- `FetchState::Partial`
|
||||
+- `FetchState::Failed`
|
||||
+
|
||||
+Only run sweep on `FetchState::Complete`.
|
||||
+Persist `run_seen_at` once per sync run and pass unchanged through all discussion/note upserts.
|
||||
+Require `run_seen_at` monotonicity per discussion before sweep (skip and warn otherwise).
|
||||
|
||||
@@ Tests to Write First
|
||||
+#[test]
|
||||
+fn test_failed_fetch_never_sweeps_even_after_partial_upserts() { ... }
|
||||
+#[test]
|
||||
+fn test_non_monotonic_run_seen_at_skips_sweep() { ... }
|
||||
+#[test]
|
||||
+fn test_retry_after_failed_fetch_then_complete_sweeps_correctly() { ... }
|
||||
```
|
||||
|
||||
3. **Add DB-level cleanup triggers for note-document referential integrity**
|
||||
Why this improves the plan: Work Chunk 0B handles the sweep path, but not every possible delete path. DB triggers give defense-in-depth so stale note docs cannot survive even if a future code path deletes notes differently.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 0B: Immediate Deletion Propagation
|
||||
-Update both sweep functions to propagate deletion to documents and dirty_sources using set-based SQL
|
||||
+Keep set-based SQL in sweep functions, and add DB-level cleanup triggers as a safety net.
|
||||
|
||||
@@ Work Chunk 2A: Schema Migration (023)
|
||||
+-- Cleanup trigger: deleting a non-system note must delete note document + dirty queue row
|
||||
+CREATE TRIGGER notes_ad_cleanup AFTER DELETE ON notes
|
||||
+WHEN old.is_system = 0
|
||||
+BEGIN
|
||||
+ DELETE FROM documents
|
||||
+ WHERE source_type = 'note' AND source_id = old.id;
|
||||
+ DELETE FROM dirty_sources
|
||||
+ WHERE source_type = 'note' AND source_id = old.id;
|
||||
+END;
|
||||
+
|
||||
+-- Cleanup trigger: if note flips to system, remove its document artifacts
|
||||
+CREATE TRIGGER notes_au_system_cleanup AFTER UPDATE OF is_system ON notes
|
||||
+WHEN old.is_system = 0 AND new.is_system = 1
|
||||
+BEGIN
|
||||
+ DELETE FROM documents
|
||||
+ WHERE source_type = 'note' AND source_id = new.id;
|
||||
+ DELETE FROM dirty_sources
|
||||
+ WHERE source_type = 'note' AND source_id = new.id;
|
||||
+END;
|
||||
```
|
||||
|
||||
4. **Eliminate N+1 extraction cost with parent metadata caching in regeneration**
|
||||
Why this improves the plan: backfilling ~8k notes with per-note parent/label lookups creates avoidable query amplification. Batch caching turns repeated joins into one-time lookups per parent entity and materially reduces rebuild time.
|
||||
|
||||
```diff
|
||||
@@ Phase 2: Per-Note Documents
|
||||
+### Work Chunk 2I: Batch Parent Metadata Cache for Note Regeneration
|
||||
+**Files:** `src/documents/regenerator.rs`, `src/documents/extractor.rs`
|
||||
+
|
||||
+#### Implementation
|
||||
+- Add `NoteExtractionContext` cache keyed by `(noteable_type, parent_id)` containing:
|
||||
+ - parent iid/title/url
|
||||
+ - parent labels
|
||||
+ - project path
|
||||
+- In batch regeneration, prefetch parent metadata for note IDs in the current chunk.
|
||||
+- Use cached metadata in `extract_note_document()` to avoid repeated parent/label queries.
|
||||
+
|
||||
+#### Tests
|
||||
+- `test_note_regeneration_uses_parent_cache_consistently`
|
||||
+- `test_note_regeneration_cache_hit_preserves_hash_determinism`
|
||||
```
|
||||
|
||||
5. **Add embedding dedup cache keyed by semantic text hash**
|
||||
Why this improves the plan: note docs will contain repeated short comments (“LGTM”, “nit: …”). Current doc-level hashing includes metadata, so identical semantic comments still re-embed many times. A semantic embedding hash cache cuts cost and speeds full rebuild/backfill without changing search behavior.
|
||||
|
||||
```diff
|
||||
@@ Phase 2: Per-Note Documents
|
||||
+### Work Chunk 2J: Semantic Embedding Dedup for Notes
|
||||
+**Files:** `migrations/026_embedding_cache.sql`, embedding pipeline module(s), `src/documents/extractor.rs`
|
||||
+
|
||||
+#### Implementation
|
||||
+- Compute `embedding_text` for notes as: normalized note body + compact stable context (`parent_type`, `path`, `resolution`), excluding volatile fields.
|
||||
+- Compute `embedding_hash = sha256(embedding_text)`.
|
||||
+- Before embedding generation, lookup existing vector by `(model, embedding_hash)`.
|
||||
+- Reuse cached vector when present; only call embedding model on misses.
|
||||
+
|
||||
+#### Tests
|
||||
+- `test_identical_note_bodies_reuse_embedding_vector`
|
||||
+- `test_embedding_hash_changes_when_semantic_context_changes`
|
||||
```
|
||||
|
||||
6. **Add deterministic review-signal tags as derived labels**
|
||||
Why this improves the plan: this makes output immediately more useful for reviewer-pattern analysis without adding a profile command (which is explicitly out of scope). It increases practical value of both `lore notes` and `lore search --type note` with low complexity.
|
||||
|
||||
```diff
|
||||
@@ Non-Goals
|
||||
-- Adding a "reviewer profile" report command (that's a downstream use case built on this infrastructure)
|
||||
+- Adding a "reviewer profile" report command (downstream), while allowing low-level derived signal tags as indexing primitives
|
||||
|
||||
@@ Phase 2: Per-Note Documents
|
||||
+### Work Chunk 2K: Derived Review Signal Labels
|
||||
+**Files:** `src/documents/extractor.rs`
|
||||
+
|
||||
+#### Implementation
|
||||
+- Derive deterministic labels from note text + metadata:
|
||||
+ - `signal:nit`
|
||||
+ - `signal:blocking`
|
||||
+ - `signal:security`
|
||||
+ - `signal:performance`
|
||||
+ - `signal:testing`
|
||||
+- Attach via existing `document_labels` flow for note documents.
|
||||
+- No new CLI mode required; existing label filters can consume these labels.
|
||||
+
|
||||
+#### Tests
|
||||
+- `test_note_document_derives_signal_labels_nit`
|
||||
+- `test_note_document_derives_signal_labels_security`
|
||||
+- `test_signal_label_derivation_is_deterministic`
|
||||
```
|
||||
|
||||
7. **Add high-precision note targeting filters (`--note-id`, `--gitlab-note-id`, `--discussion-id`)**
|
||||
Why this improves the plan: debugging, incident response, and reproducibility all benefit from exact addressing. This is especially useful when validating sync correctness and cross-checking a specific note/document lifecycle.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 1B: CLI Arguments & Command Wiring
|
||||
pub struct NotesArgs {
|
||||
+ /// Filter by local note row id
|
||||
+ #[arg(long = "note-id", help_heading = "Filters")]
|
||||
+ pub note_id: Option<i64>,
|
||||
+
|
||||
+ /// Filter by GitLab note id
|
||||
+ #[arg(long = "gitlab-note-id", help_heading = "Filters")]
|
||||
+ pub gitlab_note_id: Option<i64>,
|
||||
+
|
||||
+ /// Filter by local discussion id
|
||||
+ #[arg(long = "discussion-id", help_heading = "Filters")]
|
||||
+ pub discussion_id: Option<i64>,
|
||||
}
|
||||
|
||||
@@ Work Chunk 1A: Filter struct
|
||||
pub struct NoteListFilters<'a> {
|
||||
+ pub note_id: Option<i64>,
|
||||
+ pub gitlab_note_id: Option<i64>,
|
||||
+ pub discussion_id: Option<i64>,
|
||||
}
|
||||
|
||||
@@ Tests to Write First
|
||||
+#[test]
|
||||
+fn test_query_notes_filter_note_id_exact() { ... }
|
||||
+#[test]
|
||||
+fn test_query_notes_filter_gitlab_note_id_exact() { ... }
|
||||
+#[test]
|
||||
+fn test_query_notes_filter_discussion_id_exact() { ... }
|
||||
```
|
||||
|
||||
If you want, I can produce a single consolidated “iteration 5” PRD diff that merges these into your exact section ordering and updates the dependency graph/migration numbering end-to-end.
|
||||
@@ -1,434 +0,0 @@
|
||||
Below are the highest-leverage revisions I’d make to this plan. I’m focusing on correctness pitfalls, SQLite gotchas, query performance on 280K notes, and reducing “dynamic SQL + param juggling” complexity—without turning this into a new ingestion project.
|
||||
|
||||
Change 1 — Fix a hard SQLite bug in --active (GROUP_CONCAT DISTINCT + separator)
|
||||
Why
|
||||
|
||||
SQLite does not allow GROUP_CONCAT(DISTINCT x, sep). With DISTINCT, SQLite only permits a single argument (GROUP_CONCAT(DISTINCT x)). Your current query will error at runtime in many SQLite versions.
|
||||
|
||||
Revision
|
||||
|
||||
Use a subquery that selects distinct participants, then GROUP_CONCAT with your separator.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ fn query_active(...)
|
||||
- (SELECT GROUP_CONCAT(DISTINCT n.author_username, X'1F')
|
||||
- FROM notes n
|
||||
- WHERE n.discussion_id = d.id
|
||||
- AND n.is_system = 0
|
||||
- AND n.author_username IS NOT NULL) AS participants
|
||||
+ (SELECT GROUP_CONCAT(username, X'1F') FROM (
|
||||
+ SELECT DISTINCT n.author_username AS username
|
||||
+ FROM notes n
|
||||
+ WHERE n.discussion_id = d.id
|
||||
+ AND n.is_system = 0
|
||||
+ AND n.author_username IS NOT NULL
|
||||
+ ORDER BY username
|
||||
+ )) AS participants
|
||||
|
||||
Change 2 — Replace “contains('.') => exact file match” with segment-aware path classification
|
||||
Why
|
||||
|
||||
path.contains('.') misclassifies directories like:
|
||||
|
||||
.github/workflows/
|
||||
|
||||
src/v1.2/auth/
|
||||
|
||||
It also fails the “root file” case (README.md) because your mode discriminator only treats paths as paths if they contain /.
|
||||
|
||||
Revision
|
||||
|
||||
Add explicit --path to force Expert mode (covers root files cleanly).
|
||||
|
||||
Classify file-vs-dir by checking last path segment for a dot, and whether the input ends with /.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ pub struct WhoArgs {
|
||||
- /// Username or file path (path if contains /)
|
||||
- pub target: Option<String>,
|
||||
+ /// Username or file path shorthand (ambiguous for root files like README.md)
|
||||
+ pub target: Option<String>,
|
||||
+
|
||||
+ /// Force expert mode for a file/directory path (supports root files like README.md)
|
||||
+ #[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])]
|
||||
+ pub path: Option<String>,
|
||||
@@ fn resolve_mode<'a>(args: &'a WhoArgs) -> Result<WhoMode<'a>> {
|
||||
- if let Some(target) = &args.target {
|
||||
+ if let Some(p) = &args.path {
|
||||
+ return Ok(WhoMode::Expert { path: p });
|
||||
+ }
|
||||
+ if let Some(target) = &args.target {
|
||||
let clean = target.strip_prefix('@').unwrap_or(target);
|
||||
if args.reviews {
|
||||
return Ok(WhoMode::Reviews { username: clean });
|
||||
}
|
||||
- // Disambiguation: if target contains '/', it's a file path.
|
||||
- // GitLab usernames never contain '/'.
|
||||
- if target.contains('/') {
|
||||
+ // Disambiguation:
|
||||
+ // - treat as path if it contains '/'
|
||||
+ // - otherwise treat as username (root files require --path)
|
||||
+ if target.contains('/') {
|
||||
return Ok(WhoMode::Expert { path: target });
|
||||
}
|
||||
return Ok(WhoMode::Workload { username: clean });
|
||||
}
|
||||
|
||||
|
||||
And update the path pattern logic used by Expert/Overlap:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ fn query_expert(...)
|
||||
- // Normalize path for LIKE matching: add trailing % if no extension
|
||||
- let path_pattern = if path.contains('.') {
|
||||
- path.to_string() // Exact file match
|
||||
- } else {
|
||||
- let trimmed = path.trim_end_matches('/');
|
||||
- format!("{trimmed}/%")
|
||||
- };
|
||||
+ // Normalize:
|
||||
+ // - if ends_with('/') => directory prefix
|
||||
+ // - else if last segment contains '.' => file exact match
|
||||
+ // - else => directory prefix
|
||||
+ let trimmed = path.trim_end_matches('/');
|
||||
+ let last = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
+ let is_file = !path.ends_with('/') && last.contains('.');
|
||||
+ let path_pattern = if is_file { trimmed.to_string() } else { format!("{trimmed}/%") };
|
||||
|
||||
Change 3 — Stop building dynamic SQL strings for optional filters; always bind params
|
||||
Why
|
||||
|
||||
Right now you’re mixing:
|
||||
|
||||
dynamic project_clause string fragments
|
||||
|
||||
ad-hoc param vectors
|
||||
|
||||
placeholder renumbering by branch
|
||||
|
||||
That’s brittle and easy to regress (especially when you add more conditions later). SQLite/rusqlite can bind Option<T> to NULL, which enables a simple pattern:
|
||||
|
||||
sql
|
||||
Copy code
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
|
||||
Revision (representative; apply to all queries)
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ fn query_expert(...)
|
||||
- let project_clause = if project_id.is_some() {
|
||||
- "AND n.project_id = ?3"
|
||||
- } else {
|
||||
- ""
|
||||
- };
|
||||
-
|
||||
- let sql = format!(
|
||||
+ let sql = format!(
|
||||
"SELECT username, role, activity_count, last_active_at FROM (
|
||||
@@
|
||||
FROM notes n
|
||||
WHERE n.position_new_path LIKE ?1
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
- {project_clause}
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
@@
|
||||
WHERE n.position_new_path LIKE ?1
|
||||
AND m.author_username IS NOT NULL
|
||||
AND m.updated_at >= ?2
|
||||
- {project_clause}
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
- )"
|
||||
+ ) t"
|
||||
);
|
||||
-
|
||||
- let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
- params.push(Box::new(path_pattern.clone()));
|
||||
- params.push(Box::new(since_ms));
|
||||
- if let Some(pid) = project_id {
|
||||
- params.push(Box::new(pid));
|
||||
- }
|
||||
- let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
+ let param_refs = rusqlite::params![path_pattern, since_ms, project_id];
|
||||
|
||||
|
||||
Notes:
|
||||
|
||||
Adds required derived-table alias t (some SQLite configurations are stricter).
|
||||
|
||||
Eliminates the dynamic param vector and placeholder gymnastics.
|
||||
|
||||
Change 4 — Filter “path touch” queries to DiffNotes and escape LIKE properly
|
||||
Why
|
||||
|
||||
Only DiffNotes reliably have position_new_path; including other note types can skew counts and harm performance.
|
||||
|
||||
LIKE treats % and _ as wildcards—rare in file paths, but not impossible (generated files, templates). Escaping is a low-cost robustness win.
|
||||
|
||||
Revision
|
||||
|
||||
Add note_type='DiffNote' and LIKE ... ESCAPE '\' plus a tiny escape helper.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ fn query_expert(...)
|
||||
- FROM notes n
|
||||
- WHERE n.position_new_path LIKE ?1
|
||||
+ FROM notes n
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
+ AND n.position_new_path LIKE ?1 ESCAPE '\'
|
||||
AND n.is_system = 0
|
||||
@@
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ Helper Functions
|
||||
+fn escape_like(input: &str) -> String {
|
||||
+ input.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
|
||||
+}
|
||||
|
||||
|
||||
And when building patterns:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
- let path_pattern = if is_file { trimmed.to_string() } else { format!("{trimmed}/%") };
|
||||
+ let base = escape_like(trimmed);
|
||||
+ let path_pattern = if is_file { base } else { format!("{base}/%") };
|
||||
|
||||
|
||||
Apply the same changes to query_overlap and any other position_new_path LIKE ....
|
||||
|
||||
Change 5 — Use note timestamps for “touch since” semantics (Expert/Overlap author branch)
|
||||
Why
|
||||
|
||||
In Expert/Overlap “author” branches you filter by m.updated_at >= since. That answers “MR updated recently” rather than “MR touched at this path recently”, which can surface stale ownership.
|
||||
|
||||
Revision
|
||||
|
||||
Filter by the note creation time (and use it for “last touch” where relevant). You can still compute author activity, but anchor it to note activity.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ fn query_overlap(...)
|
||||
- WHERE n.position_new_path LIKE ?1
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
+ AND n.position_new_path LIKE ?1 ESCAPE '\'
|
||||
AND m.state IN ('opened', 'merged')
|
||||
AND m.author_username IS NOT NULL
|
||||
- AND m.updated_at >= ?2
|
||||
+ AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR m.project_id = ?3)
|
||||
|
||||
|
||||
Same idea in Expert mode’s “MR authors” branch.
|
||||
|
||||
Change 6 — Workload mode: apply --since consistently to unresolved discussions
|
||||
Why
|
||||
|
||||
Workload’s unresolved discussions ignore since_ms. That makes --since partially misleading and can dump very old threads.
|
||||
|
||||
Revision
|
||||
|
||||
Filter on d.last_note_at when since_ms is set.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ fn query_workload(...)
|
||||
- let disc_sql = format!(
|
||||
+ let disc_since = if since_ms.is_some() {
|
||||
+ "AND d.last_note_at >= ?2"
|
||||
+ } else { "" };
|
||||
+ let disc_sql = format!(
|
||||
"SELECT d.noteable_type,
|
||||
@@
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND EXISTS (
|
||||
@@
|
||||
)
|
||||
{disc_project_filter}
|
||||
+ {disc_since}
|
||||
ORDER BY d.last_note_at DESC
|
||||
LIMIT {limit}"
|
||||
);
|
||||
@@
|
||||
- // Rebuild params for discussion query (only username + optional project_id)
|
||||
- let mut disc_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
- disc_params.push(Box::new(username.to_string()));
|
||||
- if let Some(pid) = project_id {
|
||||
- disc_params.push(Box::new(pid));
|
||||
- }
|
||||
+ // Params: username, since_ms, project_id (NULLs ok)
|
||||
+ let disc_param_refs = rusqlite::params![username, since_ms, project_id];
|
||||
|
||||
|
||||
(If you adopt Change 3 fully, this becomes very clean.)
|
||||
|
||||
Change 7 — Make Overlap results represent “both roles” instead of collapsing to one
|
||||
Why
|
||||
|
||||
Collapsing to a single role loses valuable info (“they authored and reviewed”). Also your current “prefer author” rule is arbitrary for the “who else is touching this” question.
|
||||
|
||||
Revision
|
||||
|
||||
Track role counts separately and render as A, R, or A+R.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ pub struct OverlapUser {
|
||||
pub username: String,
|
||||
- pub role: String,
|
||||
- pub touch_count: u32,
|
||||
+ pub author_touch_count: u32,
|
||||
+ pub review_touch_count: u32,
|
||||
+ pub touch_count: u32,
|
||||
pub last_touch_at: i64,
|
||||
pub mr_iids: Vec<i64>,
|
||||
}
|
||||
@@ fn query_overlap(...)
|
||||
- let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapUser {
|
||||
+ let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapUser {
|
||||
username: username.clone(),
|
||||
- role: role.clone(),
|
||||
+ author_touch_count: 0,
|
||||
+ review_touch_count: 0,
|
||||
touch_count: 0,
|
||||
last_touch_at: 0,
|
||||
mr_iids: Vec::new(),
|
||||
});
|
||||
entry.touch_count += count;
|
||||
+ if role == "author" { entry.author_touch_count += count; }
|
||||
+ if role == "reviewer" { entry.review_touch_count += count; }
|
||||
@@ human output
|
||||
- println!(
|
||||
- " {:<16} {:<8} {:>7} {:<12} {}",
|
||||
+ println!(
|
||||
+ " {:<16} {:<6} {:>7} {:<12} {}",
|
||||
...
|
||||
);
|
||||
@@
|
||||
- user.role,
|
||||
+ format_roles(user.author_touch_count, user.review_touch_count),
|
||||
|
||||
Change 8 — Add an “Index Audit + optional migration” step (big perf win, low blast radius)
|
||||
Why
|
||||
|
||||
With 280K notes, the path/timestamp queries will degrade quickly without indexes. This isn’t “scope creep”; it’s making the feature usable.
|
||||
|
||||
Revision (plan-level)
|
||||
|
||||
Add a non-breaking migration that only creates indexes if missing.
|
||||
|
||||
Optionally add a runtime check: if EXPLAIN QUERY PLAN indicates full table scan on notes, print a dim warning in human mode.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ Implementation Order
|
||||
-| Step | What | Files |
|
||||
+| Step | What | Files |
|
||||
| 1 | CLI skeleton: `WhoArgs` + `Commands::Who` + dispatch + stub | `cli/mod.rs`, `commands/mod.rs`, `main.rs` |
|
||||
+| 1.5 | Index audit + add `CREATE INDEX IF NOT EXISTS` migration for who hot paths | `migrations/0xx_who_indexes.sql` |
|
||||
@@
|
||||
|
||||
|
||||
Suggested indexes (tune names to your conventions):
|
||||
|
||||
notes(note_type, position_new_path, created_at)
|
||||
|
||||
notes(discussion_id, is_system, author_username)
|
||||
|
||||
discussions(resolvable, resolved, last_note_at, project_id)
|
||||
|
||||
merge_requests(project_id, state, updated_at, author_username)
|
||||
|
||||
issue_assignees(username, issue_id)
|
||||
|
||||
Even if SQLite can’t perfectly index LIKE, these still help with join and timestamp filters.
|
||||
|
||||
Change 9 — Make robot JSON reproducible by echoing the effective query inputs
|
||||
Why
|
||||
|
||||
Agent workflows benefit from a stable “query record”: what mode ran, what path/user, resolved project, effective since, limit.
|
||||
|
||||
Revision
|
||||
|
||||
Include an input object in JSON output.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ struct WhoJsonData {
|
||||
mode: String,
|
||||
+ input: serde_json::Value,
|
||||
#[serde(flatten)]
|
||||
result: serde_json::Value,
|
||||
}
|
||||
@@ pub fn print_who_json(...)
|
||||
- let output = WhoJsonEnvelope {
|
||||
+ let input = serde_json::json!({
|
||||
+ "project": /* resolved or raw args.project */,
|
||||
+ "since": /* resolved since ISO */,
|
||||
+ "limit": /* args.limit */,
|
||||
+ });
|
||||
+ let output = WhoJsonEnvelope {
|
||||
ok: true,
|
||||
data: WhoJsonData {
|
||||
mode: mode.to_string(),
|
||||
+ input,
|
||||
result: data,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
Change 10 — Tighten clap constraints so invalid combinations never reach resolve_mode
|
||||
Why
|
||||
|
||||
Right now conflicts are enforced manually (or not at all). Clamp the invalid combos at the CLI layer:
|
||||
|
||||
--active should conflict with target, --overlap, --reviews, --path
|
||||
|
||||
--reviews should require a username (and should conflict with Expert path modes)
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@ pub struct WhoArgs {
|
||||
- pub active: bool,
|
||||
+ #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])]
|
||||
+ pub active: bool,
|
||||
@@
|
||||
- pub overlap: Option<String>,
|
||||
+ #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])]
|
||||
+ pub overlap: Option<String>,
|
||||
@@
|
||||
- pub reviews: bool,
|
||||
+ #[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])]
|
||||
+ pub reviews: bool,
|
||||
|
||||
Summary of what I’d definitely change
|
||||
|
||||
If you do nothing else, do these first:
|
||||
|
||||
Fix GROUP_CONCAT(DISTINCT ..., sep) in Active mode (runtime error).
|
||||
|
||||
Path classification: add --path, and stop using contains('.') globally.
|
||||
|
||||
Remove dynamic SQL + param vectors: always bind project_id as nullable and use (? IS NULL OR ...).
|
||||
|
||||
Filter to DiffNotes + LIKE escaping for correctness and fewer rows scanned.
|
||||
|
||||
Optional index migration: otherwise this will feel slow/non-deterministically slow depending on local DB state.
|
||||
|
||||
If you want, I can also provide a consolidated “v2 plan” as a single unified patch (one diff) rather than per-change snippets.
|
||||
@@ -1,303 +0,0 @@
|
||||
Below are the highest-leverage revisions I’d make to iteration 1 to tighten correctness, performance, and “agent usefulness” without blowing up scope. For each change: (1) rationale, (2) a focused unified diff against the plan you pasted.
|
||||
|
||||
Change 1 — Make robot “input echo” actually resolved (project_id, project_path, since_ms/iso, mode)
|
||||
Why
|
||||
|
||||
Your Design Principle #5 says the robot envelope should echo resolved inputs (“effective since, resolved project”), but the current input object echoes only raw CLI strings. Agents can’t reliably reproduce or compare runs (e.g., fuzzy project resolution may map differently over time).
|
||||
|
||||
This is also a reliability improvement: “what ran” should be computed once and propagated, not recomputed in output.
|
||||
|
||||
Plan diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@
|
||||
-5. **Robot-first reproducibility.** Robot JSON output includes an `input` object echoing the resolved query parameters (effective since, resolved project, limit) so agents can trace exactly what ran.
|
||||
+5. **Robot-first reproducibility.** Robot JSON output includes a `resolved_input` object (mode, since_ms + since_iso, resolved project_id + project_path, limit, db_path) so agents can trace exactly what ran.
|
||||
|
||||
@@
|
||||
-/// Main entry point. Resolves mode from args and dispatches.
|
||||
-pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoResult> {
|
||||
+/// Main entry point. Resolves mode + resolved inputs once, then dispatches.
|
||||
+pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
- let project_id = args
|
||||
+ let project_id = args
|
||||
.project
|
||||
.as_deref()
|
||||
.map(|p| resolve_project(&conn, p))
|
||||
.transpose()?;
|
||||
+ let project_path = project_id
|
||||
+ .map(|id| lookup_project_path(&conn, id))
|
||||
+ .transpose()?;
|
||||
|
||||
let mode = resolve_mode(args)?;
|
||||
|
||||
match mode {
|
||||
WhoMode::Expert { path } => {
|
||||
let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
||||
let result = query_expert(&conn, path, project_id, since_ms, args.limit)?;
|
||||
- Ok(WhoResult::Expert(result))
|
||||
+ Ok(WhoRun::new("expert", &db_path, project_id, project_path, since_ms, args.limit, WhoResult::Expert(result)))
|
||||
}
|
||||
@@
|
||||
}
|
||||
}
|
||||
+
|
||||
+/// Wrapper that carries resolved inputs for reproducible output.
|
||||
+pub struct WhoRun {
|
||||
+ pub mode: String,
|
||||
+ pub resolved_input: WhoResolvedInput,
|
||||
+ pub result: WhoResult,
|
||||
+}
|
||||
+
|
||||
+pub struct WhoResolvedInput {
|
||||
+ pub db_path: String,
|
||||
+ pub project_id: Option<i64>,
|
||||
+ pub project_path: Option<String>,
|
||||
+ pub since_ms: i64,
|
||||
+ pub since_iso: String,
|
||||
+ pub limit: usize,
|
||||
+}
|
||||
@@
|
||||
-pub fn print_who_json(result: &WhoResult, args: &WhoArgs, elapsed_ms: u64) {
|
||||
- let (mode, data) = match result {
|
||||
+pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
|
||||
+ let (mode, data) = match &run.result {
|
||||
WhoResult::Expert(r) => ("expert", expert_to_json(r)),
|
||||
@@
|
||||
- let input = serde_json::json!({
|
||||
+ let input = serde_json::json!({
|
||||
"target": args.target,
|
||||
"path": args.path,
|
||||
"project": args.project,
|
||||
"since": args.since,
|
||||
"limit": args.limit,
|
||||
});
|
||||
+
|
||||
+ let resolved_input = serde_json::json!({
|
||||
+ "mode": run.mode,
|
||||
+ "db_path": run.resolved_input.db_path,
|
||||
+ "project_id": run.resolved_input.project_id,
|
||||
+ "project_path": run.resolved_input.project_path,
|
||||
+ "since_ms": run.resolved_input.since_ms,
|
||||
+ "since_iso": run.resolved_input.since_iso,
|
||||
+ "limit": run.resolved_input.limit,
|
||||
+ });
|
||||
@@
|
||||
- data: WhoJsonData {
|
||||
- mode: mode.to_string(),
|
||||
- input,
|
||||
- result: data,
|
||||
- },
|
||||
+ data: WhoJsonData { mode: mode.to_string(), input, resolved_input, result: data },
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
@@
|
||||
struct WhoJsonData {
|
||||
mode: String,
|
||||
input: serde_json::Value,
|
||||
+ resolved_input: serde_json::Value,
|
||||
#[serde(flatten)]
|
||||
result: serde_json::Value,
|
||||
}
|
||||
|
||||
Change 2 — Remove dynamic SQL format!(..LIMIT {limit}) and parameterize LIMIT everywhere
|
||||
Why
|
||||
|
||||
You explicitly prefer static SQL ((?N IS NULL OR ...)) to avoid subtle bugs; but Workload/Active use format! for LIMIT. Even though limit is typed, it’s an inconsistency that complicates statement caching and encourages future string assembly creep.
|
||||
|
||||
SQLite supports LIMIT ? with bound parameters; rusqlite can bind an i64.
|
||||
|
||||
Plan diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@
|
||||
- let issues_sql = format!(
|
||||
- "SELECT ...
|
||||
- ORDER BY i.updated_at DESC
|
||||
- LIMIT {limit}"
|
||||
- );
|
||||
- let mut stmt = conn.prepare(&issues_sql)?;
|
||||
+ let issues_sql =
|
||||
+ "SELECT ...
|
||||
+ ORDER BY i.updated_at DESC
|
||||
+ LIMIT ?4";
|
||||
+ let mut stmt = conn.prepare(issues_sql)?;
|
||||
let assigned_issues: Vec<WorkloadIssue> = stmt
|
||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
||||
@@
|
||||
- let authored_sql = format!(
|
||||
- "SELECT ...
|
||||
- ORDER BY m.updated_at DESC
|
||||
- LIMIT {limit}"
|
||||
- );
|
||||
- let mut stmt = conn.prepare(&authored_sql)?;
|
||||
+ let authored_sql =
|
||||
+ "SELECT ...
|
||||
+ ORDER BY m.updated_at DESC
|
||||
+ LIMIT ?4";
|
||||
+ let mut stmt = conn.prepare(authored_sql)?;
|
||||
@@
|
||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
||||
@@
|
||||
- let reviewing_sql = format!(
|
||||
- "SELECT ...
|
||||
- ORDER BY m.updated_at DESC
|
||||
- LIMIT {limit}"
|
||||
- );
|
||||
- let mut stmt = conn.prepare(&reviewing_sql)?;
|
||||
+ let reviewing_sql =
|
||||
+ "SELECT ...
|
||||
+ ORDER BY m.updated_at DESC
|
||||
+ LIMIT ?4";
|
||||
+ let mut stmt = conn.prepare(reviewing_sql)?;
|
||||
@@
|
||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
||||
@@
|
||||
- let disc_sql = format!(
|
||||
- "SELECT ...
|
||||
- ORDER BY d.last_note_at DESC
|
||||
- LIMIT {limit}"
|
||||
- );
|
||||
- let mut stmt = conn.prepare(&disc_sql)?;
|
||||
+ let disc_sql =
|
||||
+ "SELECT ...
|
||||
+ ORDER BY d.last_note_at DESC
|
||||
+ LIMIT ?4";
|
||||
+ let mut stmt = conn.prepare(disc_sql)?;
|
||||
@@
|
||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
||||
@@
|
||||
- let sql = format!(
|
||||
- "SELECT ...
|
||||
- ORDER BY d.last_note_at DESC
|
||||
- LIMIT {limit}"
|
||||
- );
|
||||
- let mut stmt = conn.prepare(&sql)?;
|
||||
+ let sql =
|
||||
+ "SELECT ...
|
||||
+ ORDER BY d.last_note_at DESC
|
||||
+ LIMIT ?3";
|
||||
+ let mut stmt = conn.prepare(sql)?;
|
||||
@@
|
||||
- .query_map(rusqlite::params![since_ms, project_id], |row| {
|
||||
+ .query_map(rusqlite::params![since_ms, project_id, limit as i64], |row| {
|
||||
|
||||
Change 3 — Fix path matching for dotless files (LICENSE/Makefile) via “exact OR prefix” (no new flags)
|
||||
Why
|
||||
|
||||
Your improved “dot only in last segment” heuristic still fails on dotless files (LICENSE, Makefile, Dockerfile) which are common, especially at repo root. Right now they’ll be treated as directories (LICENSE/%) and silently return nothing.
|
||||
|
||||
Best minimal UX: if user provides a path that’s ambiguous (no trailing slash), match either exact file OR directory prefix.
|
||||
|
||||
Plan diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@
|
||||
-/// Build a LIKE pattern from a user-supplied path, with proper LIKE escaping.
|
||||
-///
|
||||
-/// Rules:
|
||||
-/// - If the path ends with `/`, it's a directory prefix → `escaped_path%`
|
||||
-/// - If the last path segment contains `.`, it's a file → exact match
|
||||
-/// - Otherwise, it's a directory prefix → `escaped_path/%`
|
||||
+/// Build an exact + prefix match from a user-supplied path, with proper LIKE escaping.
|
||||
+///
|
||||
+/// Rules:
|
||||
+/// - If the path ends with `/`, treat as directory-only (prefix match)
|
||||
+/// - Otherwise, treat as ambiguous: exact match OR directory prefix
|
||||
+/// (fixes dotless files like LICENSE/Makefile without requiring new flags)
|
||||
@@
|
||||
-fn build_path_pattern(path: &str) -> String {
|
||||
+struct PathMatch {
|
||||
+ exact: String,
|
||||
+ prefix: String,
|
||||
+ dir_only: bool,
|
||||
+}
|
||||
+
|
||||
+fn build_path_match(path: &str) -> PathMatch {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
- let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
- let is_file = !path.ends_with('/') && last_segment.contains('.');
|
||||
let escaped = escape_like(trimmed);
|
||||
-
|
||||
- if is_file {
|
||||
- escaped
|
||||
- } else {
|
||||
- format!("{escaped}/%")
|
||||
- }
|
||||
+ PathMatch {
|
||||
+ exact: escaped.clone(),
|
||||
+ prefix: format!("{escaped}/%"),
|
||||
+ dir_only: path.ends_with('/'),
|
||||
+ }
|
||||
}
|
||||
@@
|
||||
- let path_pattern = build_path_pattern(path);
|
||||
+ let pm = build_path_match(path);
|
||||
@@
|
||||
- AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
+ AND (
|
||||
+ (?4 = 1 AND n.position_new_path LIKE ?2 ESCAPE '\\')
|
||||
+ OR (?4 = 0 AND (n.position_new_path = ?1 OR n.position_new_path LIKE ?2 ESCAPE '\\'))
|
||||
+ )
|
||||
@@
|
||||
- let rows: Vec<(String, String, u32, i64)> = stmt
|
||||
- .query_map(rusqlite::params![path_pattern, since_ms, project_id], |row| {
|
||||
+ let rows: Vec<(String, String, u32, i64)> = stmt
|
||||
+ .query_map(rusqlite::params![pm.exact, pm.prefix, since_ms, i32::from(pm.dir_only), project_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})?
|
||||
|
||||
|
||||
(Apply the same pattern to Overlap mode.)
|
||||
|
||||
Change 4 — Consistently exclude system notes in all DiffNote-based branches (Expert/Overlap author branches currently don’t)
|
||||
Why
|
||||
|
||||
You filter n.is_system = 0 for reviewer branches, but not in the author branches of Expert/Overlap. That can skew “author touch” via system-generated diff notes or bot activity.
|
||||
|
||||
Consistency here improves correctness and also enables more aggressive partial indexing.
|
||||
|
||||
Plan diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@
|
||||
- WHERE n.note_type = 'DiffNote'
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
+ AND n.is_system = 0
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR m.project_id = ?3)
|
||||
@@
|
||||
- WHERE n.note_type = 'DiffNote'
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
+ AND n.is_system = 0
|
||||
AND m.state IN ('opened', 'merged')
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR m.project_id = ?3)
|
||||
|
||||
Change 5 — Rework Migration 017 indexes to match real predicates + add one critical notes index for discussion participation
|
||||
Why
|
||||
|
||||
(a) idx_notes_diffnote_path_created currently leads with note_type even though it’s constant via partial index. You want the leading columns to match your most selective predicates: position_new_path prefix + created_at range, with optional project_id.
|
||||
|
||||
(b) Active + Workload discussion participation repeatedly hits notes by (discussion_id, author_username); you only guarantee notes(discussion_id) is indexed. Adding a narrow partial composite index pays off immediately for both “participants” and “EXISTS user participated” checks.
|
||||
|
||||
(c) The discussions index should focus on (project_id, last_note_at) with a partial predicate; resolvable/resolved a_
|
||||
@@ -1,471 +0,0 @@
|
||||
Below are the revisions I’d make to iteration 2 to improve correctness, determinism, query-plan quality, and multi-project usability without turning this into a bigger product.
|
||||
|
||||
I’m treating your plan as the “source of truth” and showing git-diff style patches against the plan text/code blocks you included.
|
||||
|
||||
Change 1 — Fix project scoping to hit the right index (DiffNote branches)
|
||||
Why
|
||||
|
||||
Your hot-path index is:
|
||||
|
||||
idx_notes_diffnote_path_created ON notes(position_new_path, created_at, project_id) WHERE note_type='DiffNote' AND is_system=0
|
||||
|
||||
But in Expert/Overlap you sometimes scope by m.project_id = ?3 (MR table), not n.project_id = ?3 (notes table). That weakens the optimizer’s ability to use the composite notes index (and can force broader joins before filtering).
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ Query: Expert Mode @@
|
||||
- AND (?3 IS NULL OR m.project_id = ?3)
|
||||
+ -- IMPORTANT: scope on notes.project_id to maximize use of
|
||||
+ -- idx_notes_diffnote_path_created (notes is the selective table)
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
|
||||
@@ Query: Overlap Mode @@
|
||||
- AND (?3 IS NULL OR m.project_id = ?3)
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
|
||||
@@ Query: Overlap Mode (author branch) @@
|
||||
- AND (?3 IS NULL OR m.project_id = ?3)
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
|
||||
Change 2 — Introduce a “prefix vs exact” path query to avoid LIKE when you don’t need it
|
||||
Why
|
||||
|
||||
For exact file paths (e.g. src/auth/login.rs), you currently do:
|
||||
|
||||
position_new_path LIKE ?1 ESCAPE '\' where ?1 has no wildcard
|
||||
|
||||
That’s logically fine, but it’s a worse signal to the planner than = and can degrade performance depending on collation/case settings.
|
||||
|
||||
This doesn’t violate “static SQL” — you can pick between two static query strings.
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ Helper: Path Pattern Construction @@
|
||||
-fn build_path_pattern(path: &str) -> String {
|
||||
+struct PathQuery {
|
||||
+ /// The parameter value to bind.
|
||||
+ value: String,
|
||||
+ /// If true: use LIKE value || '%'. If false: use '='.
|
||||
+ is_prefix: bool,
|
||||
+}
|
||||
+
|
||||
+fn build_path_query(path: &str) -> PathQuery {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
let is_file = !path.ends_with('/') && last_segment.contains('.');
|
||||
let escaped = escape_like(trimmed);
|
||||
|
||||
if is_file {
|
||||
- escaped
|
||||
+ PathQuery { value: escaped, is_prefix: false }
|
||||
} else {
|
||||
- format!("{escaped}/%")
|
||||
+ PathQuery { value: format!("{escaped}/%"), is_prefix: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
And then (example for DiffNote predicates):
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@ Query: Expert Mode @@
|
||||
- let path_pattern = build_path_pattern(path);
|
||||
+ let pq = build_path_query(path);
|
||||
|
||||
- let sql = " ... n.position_new_path LIKE ?1 ESCAPE '\\' ... ";
|
||||
+ let sql_prefix = " ... n.position_new_path LIKE ?1 ESCAPE '\\' ... ";
|
||||
+ let sql_exact = " ... n.position_new_path = ?1 ... ";
|
||||
|
||||
- let mut stmt = conn.prepare(sql)?;
|
||||
+ let mut stmt = if pq.is_prefix { conn.prepare_cached(sql_prefix)? }
|
||||
+ else { conn.prepare_cached(sql_exact)? };
|
||||
let rows = stmt.query_map(params![... pq.value ...], ...);
|
||||
|
||||
Change 3 — Push Expert aggregation into SQL (less Rust, fewer rows, SQL-level LIMIT)
|
||||
Why
|
||||
|
||||
Right now Expert does:
|
||||
|
||||
UNION ALL
|
||||
|
||||
return per-role rows
|
||||
|
||||
HashMap merge
|
||||
|
||||
score compute
|
||||
|
||||
sort/truncate
|
||||
|
||||
You can do all of that in SQL deterministically, then LIMIT ?N actually works.
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ Query: Expert Mode @@
|
||||
- let sql = "SELECT username, role, activity_count, last_active_at FROM (
|
||||
- ...
|
||||
- )";
|
||||
+ let sql = "
|
||||
+ WITH activity AS (
|
||||
+ SELECT
|
||||
+ n.author_username AS username,
|
||||
+ 'reviewer' AS role,
|
||||
+ COUNT(*) AS cnt,
|
||||
+ MAX(n.created_at) AS last_active_at
|
||||
+ FROM notes n
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
+ AND n.is_system = 0
|
||||
+ AND n.author_username IS NOT NULL
|
||||
+ AND n.created_at >= ?2
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
+ AND (
|
||||
+ (?4 = 1 AND n.position_new_path LIKE ?1 ESCAPE '\\') OR
|
||||
+ (?4 = 0 AND n.position_new_path = ?1)
|
||||
+ )
|
||||
+ GROUP BY n.author_username
|
||||
+
|
||||
+ UNION ALL
|
||||
+
|
||||
+ SELECT
|
||||
+ m.author_username AS username,
|
||||
+ 'author' AS role,
|
||||
+ COUNT(DISTINCT m.id) AS cnt,
|
||||
+ MAX(n.created_at) AS last_active_at
|
||||
+ FROM merge_requests m
|
||||
+ JOIN discussions d ON d.merge_request_id = m.id
|
||||
+ JOIN notes n ON n.discussion_id = d.id
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
+ AND n.is_system = 0
|
||||
+ AND m.author_username IS NOT NULL
|
||||
+ AND n.created_at >= ?2
|
||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
||||
+ AND (
|
||||
+ (?4 = 1 AND n.position_new_path LIKE ?1 ESCAPE '\\') OR
|
||||
+ (?4 = 0 AND n.position_new_path = ?1)
|
||||
+ )
|
||||
+ GROUP BY m.author_username
|
||||
+ )
|
||||
+ SELECT
|
||||
+ username,
|
||||
+ SUM(CASE WHEN role='reviewer' THEN cnt ELSE 0 END) AS review_count,
|
||||
+ SUM(CASE WHEN role='author' THEN cnt ELSE 0 END) AS author_count,
|
||||
+ MAX(last_active_at) AS last_active_at,
|
||||
+ (SUM(CASE WHEN role='reviewer' THEN cnt ELSE 0 END) * 3.0) +
|
||||
+ (SUM(CASE WHEN role='author' THEN cnt ELSE 0 END) * 2.0) AS score
|
||||
+ FROM activity
|
||||
+ GROUP BY username
|
||||
+ ORDER BY score DESC, last_active_at DESC, username ASC
|
||||
+ LIMIT ?5
|
||||
+ ";
|
||||
|
||||
- // Aggregate by username: combine reviewer + author counts
|
||||
- let mut user_map: HashMap<...> = HashMap::new();
|
||||
- ...
|
||||
- experts.sort_by(...); experts.truncate(limit);
|
||||
+ // No Rust-side merge/sort needed; SQL already returns final rows.
|
||||
|
||||
Change 4 — Overlap output is ambiguous across projects: include stable MR refs (project_path!iid)
|
||||
Why
|
||||
|
||||
mr_iids: Vec<i64> is ambiguous in a multi-project DB. !123 only means something with a project.
|
||||
|
||||
Also: your MR IID dedup is currently Vec.contains() inside a loop (O(n²)). Use a HashSet.
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ OverlapResult @@
|
||||
pub struct OverlapUser {
|
||||
pub username: String,
|
||||
@@
|
||||
- pub mr_iids: Vec<i64>,
|
||||
+ /// Stable MR references like "group/project!123"
|
||||
+ pub mr_refs: Vec<String>,
|
||||
}
|
||||
|
||||
@@ Query: Overlap Mode (SQL) @@
|
||||
- GROUP_CONCAT(DISTINCT m.iid) AS mr_iids
|
||||
+ GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
+ JOIN projects p ON m.project_id = p.id
|
||||
@@
|
||||
- GROUP_CONCAT(DISTINCT m.iid) AS mr_iids
|
||||
+ GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
+ JOIN projects p ON m.project_id = p.id
|
||||
|
||||
@@ Query: Overlap Mode (Rust merge) @@
|
||||
- let mr_iids: Vec<i64> = mr_iids_csv ...
|
||||
+ let mr_refs: Vec<String> = mr_refs_csv
|
||||
+ .as_deref()
|
||||
+ .map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect())
|
||||
+ .unwrap_or_default();
|
||||
@@
|
||||
- // Merge MR IIDs, deduplicate
|
||||
- for iid in &mr_iids {
|
||||
- if !entry.mr_iids.contains(iid) {
|
||||
- entry.mr_iids.push(*iid);
|
||||
- }
|
||||
- }
|
||||
+ // Merge MR refs, deduplicate
|
||||
+ use std::collections::HashSet;
|
||||
+ let mut set: HashSet<String> = entry.mr_refs.drain(..).collect();
|
||||
+ for r in mr_refs { set.insert(r); }
|
||||
+ entry.mr_refs = set.into_iter().collect();
|
||||
|
||||
Change 5 — Active mode: avoid correlated subqueries by preselecting discussions, then aggregating notes once
|
||||
Why
|
||||
|
||||
Your Active query does two correlated subqueries per discussion row:
|
||||
|
||||
note_count
|
||||
|
||||
participants
|
||||
|
||||
With LIMIT 20 it’s not catastrophic, but it is still unnecessary work and creates “spiky” behavior if the planner chooses poorly.
|
||||
|
||||
Pattern to use:
|
||||
|
||||
CTE selects the limited set of discussions
|
||||
|
||||
Join notes once, aggregate with GROUP BY
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ Query: Active Mode @@
|
||||
- let sql =
|
||||
- "SELECT
|
||||
- d.noteable_type,
|
||||
- ...
|
||||
- (SELECT COUNT(*) FROM notes n
|
||||
- WHERE n.discussion_id = d.id AND n.is_system = 0) AS note_count,
|
||||
- (SELECT GROUP_CONCAT(username, X'1F') FROM (
|
||||
- SELECT DISTINCT n.author_username AS username
|
||||
- FROM notes n
|
||||
- WHERE n.discussion_id = d.id
|
||||
- AND n.is_system = 0
|
||||
- AND n.author_username IS NOT NULL
|
||||
- ORDER BY username
|
||||
- )) AS participants
|
||||
- FROM discussions d
|
||||
- ...
|
||||
- LIMIT ?3";
|
||||
+ let sql = "
|
||||
+ WITH picked AS (
|
||||
+ SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, d.project_id, d.last_note_at
|
||||
+ FROM discussions d
|
||||
+ WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
+ AND d.last_note_at >= ?1
|
||||
+ AND (?2 IS NULL OR d.project_id = ?2)
|
||||
+ ORDER BY d.last_note_at DESC
|
||||
+ LIMIT ?3
|
||||
+ ),
|
||||
+ note_agg AS (
|
||||
+ SELECT
|
||||
+ n.discussion_id,
|
||||
+ COUNT(*) AS note_count,
|
||||
+ GROUP_CONCAT(n.author_username, X'1F') AS participants
|
||||
+ FROM (
|
||||
+ SELECT DISTINCT discussion_id, author_username
|
||||
+ FROM notes
|
||||
+ WHERE is_system = 0 AND author_username IS NOT NULL
|
||||
+ ) n
|
||||
+ JOIN picked p ON p.id = n.discussion_id
|
||||
+ GROUP BY n.discussion_id
|
||||
+ )
|
||||
+ SELECT
|
||||
+ p.noteable_type,
|
||||
+ COALESCE(i.iid, m.iid) AS entity_iid,
|
||||
+ COALESCE(i.title, m.title) AS entity_title,
|
||||
+ proj.path_with_namespace,
|
||||
+ p.last_note_at,
|
||||
+ COALESCE(na.note_count, 0) AS note_count,
|
||||
+ COALESCE(na.participants, '') AS participants
|
||||
+ FROM picked p
|
||||
+ JOIN projects proj ON p.project_id = proj.id
|
||||
+ LEFT JOIN issues i ON p.issue_id = i.id
|
||||
+ LEFT JOIN merge_requests m ON p.merge_request_id = m.id
|
||||
+ LEFT JOIN note_agg na ON na.discussion_id = p.id
|
||||
+ ORDER BY p.last_note_at DESC
|
||||
+ ";
|
||||
|
||||
Change 6 — Use prepare_cached() everywhere (cheap perf win, no scope creep)
|
||||
Why
|
||||
|
||||
You already worked hard to keep SQL static. Taking advantage of sqlite statement caching completes the loop.
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ Query functions @@
|
||||
- let mut stmt = conn.prepare(sql)?;
|
||||
+ let mut stmt = conn.prepare_cached(sql)?;
|
||||
|
||||
|
||||
Apply in all query fns (query_workload, query_reviews, query_active, query_expert, query_overlap, lookup_project_path).
|
||||
|
||||
Change 7 — Human output: show project_path where ambiguity exists (Workload + Overlap)
|
||||
Why
|
||||
|
||||
When not project-scoped, #42 and !100 aren’t unique. You already have project paths in the query results — you’re just not printing them.
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ print_workload_human @@
|
||||
- println!(
|
||||
- " {} {} {}",
|
||||
+ println!(
|
||||
+ " {} {} {} {}",
|
||||
style(format!("#{:<5}", item.iid)).cyan(),
|
||||
truncate_str(&item.title, 45),
|
||||
style(format_relative_time(item.updated_at)).dim(),
|
||||
+ style(&item.project_path).dim(),
|
||||
);
|
||||
|
||||
@@ print_workload_human (MRs) @@
|
||||
- println!(
|
||||
- " {} {}{} {}",
|
||||
+ println!(
|
||||
+ " {} {}{} {} {}",
|
||||
style(format!("!{:<5}", mr.iid)).cyan(),
|
||||
truncate_str(&mr.title, 40),
|
||||
style(draft).dim(),
|
||||
style(format_relative_time(mr.updated_at)).dim(),
|
||||
+ style(&mr.project_path).dim(),
|
||||
);
|
||||
|
||||
@@ print_overlap_human @@
|
||||
- let mr_str = user.mr_iids.iter().take(5).map(|iid| format!("!{iid}")).collect::<Vec<_>>().join(", ");
|
||||
+ let mr_str = user.mr_refs.iter().take(5).cloned().collect::<Vec<_>>().join(", ");
|
||||
|
||||
Change 8 — Robot JSON: add stable IDs + “defaulted” flags for reproducibility
|
||||
Why
|
||||
|
||||
You already added resolved_input — good. Two more reproducibility gaps remain:
|
||||
|
||||
Agents can’t reliably “open” an entity without IDs (discussion_id, mr_id, issue_id).
|
||||
|
||||
Agents can’t tell whether since was user-provided vs defaulted (important when replaying intent).
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ WhoResolvedInput @@
|
||||
pub struct WhoResolvedInput {
|
||||
@@
|
||||
pub since_ms: Option<i64>,
|
||||
pub since_iso: Option<String>,
|
||||
+ pub since_was_default: bool,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
@@ run_who @@
|
||||
- let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
||||
+ let since_was_default = args.since.is_none();
|
||||
+ let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
||||
Ok(WhoRun {
|
||||
resolved_input: WhoResolvedInput {
|
||||
@@
|
||||
since_ms: Some(since_ms),
|
||||
since_iso: Some(ms_to_iso(since_ms)),
|
||||
+ since_was_default,
|
||||
limit: args.limit,
|
||||
},
|
||||
|
||||
@@ print_who_json resolved_input @@
|
||||
let resolved_input = serde_json::json!({
|
||||
@@
|
||||
"since_ms": run.resolved_input.since_ms,
|
||||
"since_iso": run.resolved_input.since_iso,
|
||||
+ "since_was_default": run.resolved_input.since_was_default,
|
||||
"limit": run.resolved_input.limit,
|
||||
});
|
||||
|
||||
|
||||
And for Active/Workload discussion items, add IDs in SQL and JSON:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@ ActiveDiscussion @@
|
||||
pub struct ActiveDiscussion {
|
||||
+ pub discussion_id: i64,
|
||||
@@
|
||||
}
|
||||
|
||||
@@ query_active SELECT @@
|
||||
- SELECT
|
||||
- p.noteable_type,
|
||||
+ SELECT
|
||||
+ p.id AS discussion_id,
|
||||
+ p.noteable_type,
|
||||
|
||||
@@ active_to_json @@
|
||||
- "discussions": r.discussions.iter().map(|d| json!({
|
||||
+ "discussions": r.discussions.iter().map(|d| json!({
|
||||
+ "discussion_id": d.discussion_id,
|
||||
...
|
||||
}))
|
||||
|
||||
Change 9 — Make performance verification explicit: require EXPLAIN QUERY PLAN checks for each mode
|
||||
Why
|
||||
|
||||
You’re adding indexes specifically for these queries. The only way to ensure the planner is doing what you think is to lock in a short perf checklist (especially after schema drift or SQLite version differences).
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
--- a/who-command-design.md
|
||||
+++ b/who-command-design.md
|
||||
@@ Verification @@
|
||||
# Manual verification against real data
|
||||
cargo run --release -- who src/features/global-search/
|
||||
@@
|
||||
cargo run --release -- who src/features/global-search/ -p typescript # project scoped
|
||||
+
|
||||
+# Perf verification (required before merge):
|
||||
+# Confirm idx_notes_diffnote_path_created is used for Expert/Overlap and
|
||||
+# idx_discussions_unresolved_recent is used for Active.
|
||||
+sqlite3 path/to/db.sqlite "
|
||||
+ EXPLAIN QUERY PLAN
|
||||
+ SELECT ... -- paste final Expert SQL with representative bindings
|
||||
+";
|
||||
|
||||
|
||||
(Keep it lightweight: one representative query per mode is enough.)
|
||||
|
||||
Net effect
|
||||
|
||||
Correctness: project scoping hits the notes index; IDs added for agent workflows.
|
||||
|
||||
Performance: fewer rows/materialization in Expert; statement caching everywhere; Active avoids correlated subqueries.
|
||||
|
||||
UX: human output no longer ambiguous across projects; Overlap MR references become actionable.
|
||||
|
||||
Reproducibility: agents can distinguish defaults vs explicit inputs; can dereference entities reliably.
|
||||
|
||||
If you want one “highest ROI” subset to implement first: Change 1 + Change 4 + Change 6 + Change 7. That’s where the real operational value lands.
|
||||
@@ -1,3 +0,0 @@
|
||||
ChatGPT said:
|
||||
Proposing code revisions for performance and determinism
|
||||
Answer now
|
||||
@@ -1,356 +0,0 @@
|
||||
Below are the highest-leverage revisions I’d make. They’re tightly scoped (no new tables/APIs), but fix a few real correctness issues and make the outputs more actionable.
|
||||
|
||||
1) Fix a correctness bug in PathQuery: don’t escape for =, and make --path Makefile actually work
|
||||
Why
|
||||
|
||||
Bug: build_path_query() currently runs escape_like() even when is_prefix = false (exact match). That will break exact matches for paths containing _, %, or \ because = does not treat those as metacharacters (so the escaped string won’t equal the stored path).
|
||||
|
||||
UX mismatch: The plan says --path handles dotless root files (Makefile/LICENSE), but the current logic still treats them as directory prefixes (Makefile/%) → zero results.
|
||||
|
||||
Change
|
||||
|
||||
Only escape for LIKE.
|
||||
|
||||
Treat root paths (no /) passed via --path as exact matches by default (unless they end with /).
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
-/// Build a path query from a user-supplied path.
|
||||
-///
|
||||
-/// Rules:
|
||||
-/// - If the path ends with `/`, it's a directory prefix -> `escaped_path%` (LIKE)
|
||||
-/// - If the last path segment contains `.`, it's a file -> exact match (=)
|
||||
-/// - Otherwise, it's a directory prefix -> `escaped_path/%` (LIKE)
|
||||
+/// Build a path query from a user-supplied path.
|
||||
+///
|
||||
+/// Rules:
|
||||
+/// - If the path ends with `/`, it's a directory prefix -> `escaped_path/%` (LIKE)
|
||||
+/// - If the path is a root path (no `/`) and does NOT end with `/`, treat as exact (=)
|
||||
+/// (this makes `--path Makefile` and `--path LICENSE` work as intended)
|
||||
+/// - Else if the last path segment contains `.`, treat as exact (=)
|
||||
+/// - Otherwise, treat as directory prefix -> `escaped_path/%` (LIKE)
|
||||
@@
|
||||
-fn build_path_query(path: &str) -> PathQuery {
|
||||
+fn build_path_query(path: &str) -> PathQuery {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
- let is_file = !path.ends_with('/') && last_segment.contains('.');
|
||||
- let escaped = escape_like(trimmed);
|
||||
+ let is_root = !trimmed.contains('/');
|
||||
+ let is_file = !path.ends_with('/') && (is_root || last_segment.contains('.'));
|
||||
|
||||
if is_file {
|
||||
PathQuery {
|
||||
- value: escaped,
|
||||
+ // IMPORTANT: do NOT escape for exact match (=)
|
||||
+ value: trimmed.to_string(),
|
||||
is_prefix: false,
|
||||
}
|
||||
} else {
|
||||
+ let escaped = escape_like(trimmed);
|
||||
PathQuery {
|
||||
value: format!("{escaped}/%"),
|
||||
is_prefix: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@
|
||||
-/// **Known limitation:** Dotless root files (LICENSE, Makefile, Dockerfile)
|
||||
-/// without a trailing `/` will be treated as directory prefixes. Use `--path`
|
||||
-/// for these — the `--path` flag passes through to Expert mode directly,
|
||||
-/// and the `build_path_query` output for "LICENSE" is a prefix `LICENSE/%`
|
||||
-/// which will simply return zero results (a safe, obvious failure mode that the
|
||||
-/// help text addresses).
|
||||
+/// Note: Root file paths passed via `--path` (including dotless files like Makefile/LICENSE)
|
||||
+/// are treated as exact matches unless they end with `/`.
|
||||
|
||||
|
||||
Also update the --path help text to be explicit:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
- /// Force expert mode for a file/directory path (handles root files like
|
||||
- /// README.md, LICENSE, Makefile that lack a / and can't be auto-detected)
|
||||
+ /// Force expert mode for a file/directory path.
|
||||
+ /// Root files (README.md, LICENSE, Makefile) are treated as exact matches.
|
||||
+ /// Use a trailing `/` to force directory-prefix matching.
|
||||
|
||||
2) Fix Active mode: your note_count is currently counting participants, and the CTE scans too broadly
|
||||
Why
|
||||
|
||||
In note_agg, you do SELECT DISTINCT discussion_id, author_username and then COUNT(*) AS note_count. That’s participant count, not note count.
|
||||
|
||||
The current note_agg also builds the DISTINCT set from all notes then joins to picked. It’s avoidable work.
|
||||
|
||||
Change
|
||||
|
||||
Split into two aggregations scoped to picked:
|
||||
|
||||
note_counts: counts non-system notes per picked discussion.
|
||||
|
||||
participants: distinct usernames per picked discussion, then GROUP_CONCAT.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
- note_agg AS (
|
||||
- SELECT
|
||||
- n.discussion_id,
|
||||
- COUNT(*) AS note_count,
|
||||
- GROUP_CONCAT(n.author_username, X'1F') AS participants
|
||||
- FROM (
|
||||
- SELECT DISTINCT discussion_id, author_username
|
||||
- FROM notes
|
||||
- WHERE is_system = 0 AND author_username IS NOT NULL
|
||||
- ) n
|
||||
- JOIN picked p ON p.id = n.discussion_id
|
||||
- GROUP BY n.discussion_id
|
||||
- )
|
||||
+ note_counts AS (
|
||||
+ SELECT
|
||||
+ n.discussion_id,
|
||||
+ COUNT(*) AS note_count
|
||||
+ FROM notes n
|
||||
+ JOIN picked p ON p.id = n.discussion_id
|
||||
+ WHERE n.is_system = 0
|
||||
+ GROUP BY n.discussion_id
|
||||
+ ),
|
||||
+ participants AS (
|
||||
+ SELECT
|
||||
+ x.discussion_id,
|
||||
+ GROUP_CONCAT(x.author_username, X'1F') AS participants
|
||||
+ FROM (
|
||||
+ SELECT DISTINCT n.discussion_id, n.author_username
|
||||
+ FROM notes n
|
||||
+ JOIN picked p ON p.id = n.discussion_id
|
||||
+ WHERE n.is_system = 0 AND n.author_username IS NOT NULL
|
||||
+ ) x
|
||||
+ GROUP BY x.discussion_id
|
||||
+ )
|
||||
@@
|
||||
- LEFT JOIN note_agg na ON na.discussion_id = p.id
|
||||
+ LEFT JOIN note_counts nc ON nc.discussion_id = p.id
|
||||
+ LEFT JOIN participants pa ON pa.discussion_id = p.id
|
||||
@@
|
||||
- COALESCE(na.note_count, 0) AS note_count,
|
||||
- COALESCE(na.participants, '') AS participants
|
||||
+ COALESCE(nc.note_count, 0) AS note_count,
|
||||
+ COALESCE(pa.participants, '') AS participants
|
||||
|
||||
|
||||
Net effect: correctness fix + more predictable perf.
|
||||
|
||||
Add a test that would have failed before:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
#[test]
|
||||
fn test_active_query() {
|
||||
@@
|
||||
- insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work");
|
||||
+ insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work");
|
||||
+ insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up");
|
||||
@@
|
||||
- assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
|
||||
+ assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
|
||||
+ assert_eq!(result.discussions[0].note_count, 2);
|
||||
|
||||
3) Index fix: idx_discussions_unresolved_recent won’t help global --active ordering
|
||||
Why
|
||||
|
||||
Your index is (project_id, last_note_at) with WHERE resolvable=1 AND resolved=0.
|
||||
|
||||
When --active is not project-scoped (common default), SQLite can’t use (project_id, last_note_at) to satisfy ORDER BY last_note_at DESC efficiently because project_id isn’t constrained.
|
||||
|
||||
This can turn into a scan+sort over potentially large unresolved sets.
|
||||
|
||||
Change
|
||||
|
||||
Keep the project-scoped index, but add a global ordering index (partial, still small):
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent
|
||||
ON discussions(project_id, last_note_at)
|
||||
WHERE resolvable = 1 AND resolved = 0;
|
||||
+
|
||||
+-- Active (global): unresolved discussions by recency (no project scope).
|
||||
+-- Supports ORDER BY last_note_at DESC LIMIT N when project_id is unconstrained.
|
||||
+CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent_global
|
||||
+ ON discussions(last_note_at)
|
||||
+ WHERE resolvable = 1 AND resolved = 0;
|
||||
|
||||
4) Make Overlap “touches” coherent: count MRs for reviewers, not DiffNotes
|
||||
Why
|
||||
|
||||
Overlap’s question is “Who else has MRs touching my files?” but:
|
||||
|
||||
reviewer branch uses COUNT(*) (DiffNotes)
|
||||
|
||||
author branch uses COUNT(DISTINCT m.id) (MRs)
|
||||
|
||||
Those are different units; summing them into touch_count is misleading.
|
||||
|
||||
Change
|
||||
|
||||
Count distinct MRs on the reviewer branch too:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
- COUNT(*) AS touch_count,
|
||||
+ COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_touch_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
|
||||
|
||||
Also update human output labeling:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
- style("Touches").bold(),
|
||||
+ style("MRs").bold(),
|
||||
|
||||
|
||||
(You still preserve “strength” via mr_refs and last_touch_at.)
|
||||
|
||||
5) Make outputs more actionable: add a canonical ref field (group/project!iid, group/project#iid)
|
||||
Why
|
||||
|
||||
You already do this for Overlap (mr_refs). Doing the same for Workload and Active reduces friction for both humans and agents:
|
||||
|
||||
humans can copy/paste a single token
|
||||
|
||||
robots don’t need to stitch project_path + iid + prefix
|
||||
|
||||
Change (Workload structs + SQL)
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
pub struct WorkloadIssue {
|
||||
pub iid: i64,
|
||||
+ pub ref_: String,
|
||||
pub title: String,
|
||||
pub project_path: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
@@
|
||||
pub struct WorkloadMr {
|
||||
pub iid: i64,
|
||||
+ pub ref_: String,
|
||||
pub title: String,
|
||||
pub draft: bool,
|
||||
pub project_path: String,
|
||||
@@
|
||||
- let issues_sql =
|
||||
- "SELECT i.iid, i.title, p.path_with_namespace, i.updated_at
|
||||
+ let issues_sql =
|
||||
+ "SELECT i.iid,
|
||||
+ (p.path_with_namespace || '#' || i.iid) AS ref,
|
||||
+ i.title, p.path_with_namespace, i.updated_at
|
||||
@@
|
||||
- iid: row.get(0)?,
|
||||
- title: row.get(1)?,
|
||||
- project_path: row.get(2)?,
|
||||
- updated_at: row.get(3)?,
|
||||
+ iid: row.get(0)?,
|
||||
+ ref_: row.get(1)?,
|
||||
+ title: row.get(2)?,
|
||||
+ project_path: row.get(3)?,
|
||||
+ updated_at: row.get(4)?,
|
||||
})
|
||||
@@
|
||||
- let authored_sql =
|
||||
- "SELECT m.iid, m.title, m.draft, p.path_with_namespace, m.updated_at
|
||||
+ let authored_sql =
|
||||
+ "SELECT m.iid,
|
||||
+ (p.path_with_namespace || '!' || m.iid) AS ref,
|
||||
+ m.title, m.draft, p.path_with_namespace, m.updated_at
|
||||
@@
|
||||
- iid: row.get(0)?,
|
||||
- title: row.get(1)?,
|
||||
- draft: row.get::<_, i32>(2)? != 0,
|
||||
- project_path: row.get(3)?,
|
||||
+ iid: row.get(0)?,
|
||||
+ ref_: row.get(1)?,
|
||||
+ title: row.get(2)?,
|
||||
+ draft: row.get::<_, i32>(3)? != 0,
|
||||
+ project_path: row.get(4)?,
|
||||
author_username: None,
|
||||
- updated_at: row.get(4)?,
|
||||
+ updated_at: row.get(5)?,
|
||||
})
|
||||
|
||||
|
||||
Then use ref_ in human output + robot JSON.
|
||||
|
||||
6) Reviews mode: tolerate leading whitespace before **prefix**
|
||||
Why
|
||||
|
||||
Many people write " **suggestion**: ...". Current LIKE '**%**%' misses that.
|
||||
|
||||
Change
|
||||
|
||||
Use ltrim(n.body) consistently:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
- AND n.body LIKE '**%**%'
|
||||
+ AND ltrim(n.body) LIKE '**%**%'
|
||||
@@
|
||||
- SUBSTR(n.body, 3, INSTR(SUBSTR(n.body, 3), '**') - 1) AS raw_prefix,
|
||||
+ SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
|
||||
|
||||
7) Add two small tests that catch the above regressions
|
||||
Why
|
||||
|
||||
These are exactly the kind of issues that slip through without targeted tests.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/plan.md b/plan.md
|
||||
@@
|
||||
#[test]
|
||||
fn test_escape_like() {
|
||||
@@
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_build_path_query_exact_does_not_escape() {
|
||||
+ // '_' must not be escaped for '='
|
||||
+ let pq = build_path_query("README_with_underscore.md");
|
||||
+ assert_eq!(pq.value, "README_with_underscore.md");
|
||||
+ assert!(!pq.is_prefix);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_path_flag_dotless_root_file_is_exact() {
|
||||
+ let pq = build_path_query("Makefile");
|
||||
+ assert_eq!(pq.value, "Makefile");
|
||||
+ assert!(!pq.is_prefix);
|
||||
+ }
|
||||
|
||||
Summary of net effect
|
||||
|
||||
Correctness fixes: exact-path escaping bug; Active.note_count bug.
|
||||
|
||||
Perf fixes: global --active index; avoid broad note scans in Active.
|
||||
|
||||
Usefulness upgrades: coherent overlap “touch” metric; canonical refs everywhere; reviews prefix more robust.
|
||||
|
||||
If you want one extra “stretch” that still isn’t scope creep: add an unscoped warning line in human output when project_id == None (e.g., “Aggregated across projects; use -p to scope”) for Expert/Overlap/Active. That’s pure presentation, but prevents misinterpretation in multi-project DBs.
|
||||
@@ -1,471 +0,0 @@
|
||||
Proposed revisions (Iteration 6)
|
||||
|
||||
Below are the highest-leverage changes I’d make on top of your current Iteration 5 plan, with rationale and git-diff style edits to the plan text/snippets.
|
||||
|
||||
1) Fix a real edge case: dotless non-root files (src/Dockerfile, infra/Makefile, etc.)
|
||||
Why
|
||||
|
||||
Your current build_path_query() treats dotless last segments as directories (prefix match) unless the path is root. That misclassifies legitimate dotless files inside directories and silently produces path/% (zero hits or wrong hits).
|
||||
|
||||
Best minimal fix: keep your static SQL approach, but add a DB existence probe (static SQL) for path queries:
|
||||
|
||||
If user didn’t force directory (/), and exact path exists in DiffNotes, treat as exact =.
|
||||
|
||||
Otherwise use prefix LIKE 'dir/%'.
|
||||
|
||||
This avoids new CLI flags, avoids heuristics lists, and uses your existing partial index (idx_notes_diffnote_path_created) efficiently.
|
||||
|
||||
Diff
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/Plan.md b/Plan.md
|
||||
@@
|
||||
-struct PathQuery {
|
||||
+struct PathQuery {
|
||||
/// The parameter value to bind.
|
||||
value: String,
|
||||
/// If true: use `LIKE value ESCAPE '\'`. If false: use `= value`.
|
||||
is_prefix: bool,
|
||||
}
|
||||
|
||||
-/// Build a path query from a user-supplied path.
|
||||
+/// Build a path query from a user-supplied path, with a DB probe for dotless files.
|
||||
@@
|
||||
-fn build_path_query(path: &str) -> PathQuery {
|
||||
+fn build_path_query(conn: &Connection, path: &str) -> Result<PathQuery> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
let is_root = !trimmed.contains('/');
|
||||
- let is_file = !path.ends_with('/') && (is_root || last_segment.contains('.'));
|
||||
+ let forced_dir = path.ends_with('/');
|
||||
+ let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||
+
|
||||
+ // If it doesn't "look like a file" but the exact path exists in DiffNotes,
|
||||
+ // treat as exact (handles src/Dockerfile, infra/Makefile, etc.).
|
||||
+ let exact_exists = if !looks_like_file && !forced_dir {
|
||||
+ conn.query_row(
|
||||
+ "SELECT 1
|
||||
+ FROM notes
|
||||
+ WHERE note_type = 'DiffNote'
|
||||
+ AND is_system = 0
|
||||
+ AND position_new_path = ?1
|
||||
+ LIMIT 1",
|
||||
+ rusqlite::params![trimmed],
|
||||
+ |_| Ok(()),
|
||||
+ ).is_ok()
|
||||
+ } else {
|
||||
+ false
|
||||
+ };
|
||||
+
|
||||
+ let is_file = looks_like_file || exact_exists;
|
||||
|
||||
if is_file {
|
||||
PathQuery {
|
||||
value: trimmed.to_string(),
|
||||
is_prefix: false,
|
||||
}
|
||||
} else {
|
||||
let escaped = escape_like(trimmed);
|
||||
PathQuery {
|
||||
value: format!("{escaped}/%"),
|
||||
is_prefix: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Also update callers:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- let pq = build_path_query(path);
|
||||
+ let pq = build_path_query(conn, path)?;
|
||||
@@
|
||||
- let pq = build_path_query(path);
|
||||
+ let pq = build_path_query(conn, path)?;
|
||||
|
||||
|
||||
And tests:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- fn test_build_path_query() {
|
||||
+ fn test_build_path_query() {
|
||||
@@
|
||||
- // Dotless root file -> exact match (root path without '/')
|
||||
+ // Dotless root file -> exact match (root path without '/')
|
||||
let pq = build_path_query("Makefile");
|
||||
assert_eq!(pq.value, "Makefile");
|
||||
assert!(!pq.is_prefix);
|
||||
+
|
||||
+ // Dotless file in subdir should become exact if DB contains it (probe)
|
||||
+ // (set up: insert one DiffNote with position_new_path = "src/Dockerfile")
|
||||
|
||||
2) Make “reviewer” semantics correct: exclude MR authors commenting on their own diffs
|
||||
Why
|
||||
|
||||
Right now, Overlap (and Expert reviewer branch) will count MR authors as “reviewers” if they leave DiffNotes in their own MR (clarifications / replies), inflating A+R and contaminating “who reviewed here” signals.
|
||||
|
||||
You already enforce this in --reviews mode (m.author_username != ?1). Apply the same principle consistently:
|
||||
|
||||
Reviewer branch: only count notes where n.author_username != m.author_username (when both non-NULL).
|
||||
|
||||
Diff (Overlap reviewer branch)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- WHERE n.note_type = 'DiffNote'
|
||||
+ WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
+ AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
|
||||
|
||||
Same change for sql_exact.
|
||||
|
||||
3) Expert mode scoring: align units + reduce single-MR “comment storms”
|
||||
Why
|
||||
|
||||
Expert currently mixes units:
|
||||
|
||||
reviewer side: DiffNote count
|
||||
|
||||
author side: distinct MR count
|
||||
|
||||
That makes score noisy and can crown “someone who wrote 30 comments on one MR” as top expert.
|
||||
|
||||
Fix: make both sides primarily MR-breadth:
|
||||
|
||||
reviewer: COUNT(DISTINCT m.id) as review_mr_count
|
||||
|
||||
author: COUNT(DISTINCT m.id) as author_mr_count
|
||||
Optionally keep review_note_count as a secondary intensity signal (but not the main driver).
|
||||
|
||||
Diff (types + SQL)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
pub struct Expert {
|
||||
pub username: String,
|
||||
- pub score: f64,
|
||||
- pub review_count: u32,
|
||||
- pub author_count: u32,
|
||||
+ pub score: i64,
|
||||
+ pub review_mr_count: u32,
|
||||
+ pub review_note_count: u32,
|
||||
+ pub author_mr_count: u32,
|
||||
pub last_active_ms: i64,
|
||||
}
|
||||
|
||||
|
||||
Reviewer branch now joins to MR so it can count distinct MRs and exclude self-comments:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- SELECT
|
||||
- n.author_username AS username,
|
||||
- 'reviewer' AS role,
|
||||
- COUNT(*) AS cnt,
|
||||
- MAX(n.created_at) AS last_active_at
|
||||
- FROM notes n
|
||||
+ SELECT
|
||||
+ n.author_username AS username,
|
||||
+ 'reviewer' AS role,
|
||||
+ COUNT(DISTINCT m.id) AS mr_cnt,
|
||||
+ COUNT(*) AS note_cnt,
|
||||
+ MAX(n.created_at) AS last_active_at
|
||||
+ FROM notes n
|
||||
+ JOIN discussions d ON n.discussion_id = d.id
|
||||
+ JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
+ AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
|
||||
Update author branch payload to match shape:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
- COUNT(DISTINCT m.id) AS cnt,
|
||||
+ COUNT(DISTINCT m.id) AS mr_cnt,
|
||||
+ 0 AS note_cnt,
|
||||
MAX(n.created_at) AS last_active_at
|
||||
|
||||
|
||||
Aggregate:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
SELECT
|
||||
username,
|
||||
- SUM(CASE WHEN role = 'reviewer' THEN cnt ELSE 0 END) AS review_count,
|
||||
- SUM(CASE WHEN role = 'author' THEN cnt ELSE 0 END) AS author_count,
|
||||
+ SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) AS review_mr_count,
|
||||
+ SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) AS review_note_count,
|
||||
+ SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) AS author_mr_count,
|
||||
MAX(last_active_at) AS last_active_at,
|
||||
- (SUM(CASE WHEN role = 'reviewer' THEN cnt ELSE 0 END) * 3.0) +
|
||||
- (SUM(CASE WHEN role = 'author' THEN cnt ELSE 0 END) * 2.0) AS score
|
||||
+ (
|
||||
+ (SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) * 20) +
|
||||
+ (SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) * 12) +
|
||||
+ (SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) * 1)
|
||||
+ ) AS score
|
||||
|
||||
|
||||
Human header:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- style("Reviews").bold(),
|
||||
- style("Authored").bold(),
|
||||
+ style("Reviewed(MRs)").bold(),
|
||||
+ style("Notes").bold(),
|
||||
+ style("Authored(MRs)").bold(),
|
||||
|
||||
4) Deterministic output: participants + MR refs + tie-breakers
|
||||
Why
|
||||
|
||||
You’ve correctly focused on reproducibility (resolved_input), but you still have nondeterministic lists:
|
||||
|
||||
participants: GROUP_CONCAT order is undefined → vector order changes run-to-run.
|
||||
|
||||
mr_refs: you dedup via HashSet then iterate → undefined order.
|
||||
|
||||
user sorting in overlap is missing stable tie-breakers.
|
||||
|
||||
This is a real “robot mode flake” source.
|
||||
|
||||
Diff (Active participants sort)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- let participants: Vec<String> = participants_csv
|
||||
+ let mut participants: Vec<String> = participants_csv
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|csv| csv.split('\x1F').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
+ participants.sort(); // stable, deterministic
|
||||
|
||||
Diff (Overlap MR refs sort + stable user sort)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- users.sort_by(|a, b| b.touch_count.cmp(&a.touch_count));
|
||||
+ users.sort_by(|a, b| {
|
||||
+ b.touch_count.cmp(&a.touch_count)
|
||||
+ .then_with(|| b.last_touch_at.cmp(&a.last_touch_at))
|
||||
+ .then_with(|| a.username.cmp(&b.username))
|
||||
+ });
|
||||
@@
|
||||
- entry.mr_refs = set.into_iter().collect();
|
||||
+ let mut v: Vec<String> = set.into_iter().collect();
|
||||
+ v.sort();
|
||||
+ entry.mr_refs = v;
|
||||
|
||||
5) Make --limit actionable: surface truncation explicitly (human + robot)
|
||||
Why
|
||||
|
||||
Agents (and humans) need to know if results were cut off so they can rerun with a bigger -n.
|
||||
Right now there’s no signal.
|
||||
|
||||
Minimal pattern: query limit + 1, set truncated = true if you got > limit, then truncate.
|
||||
|
||||
Diff (result types)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
pub struct ExpertResult {
|
||||
pub path_query: String,
|
||||
pub experts: Vec<Expert>,
|
||||
+ pub truncated: bool,
|
||||
}
|
||||
@@
|
||||
pub struct ActiveResult {
|
||||
pub discussions: Vec<ActiveDiscussion>,
|
||||
pub total_unresolved: u32,
|
||||
+ pub truncated: bool,
|
||||
}
|
||||
@@
|
||||
pub struct OverlapResult {
|
||||
pub path_query: String,
|
||||
pub users: Vec<OverlapUser>,
|
||||
+ pub truncated: bool,
|
||||
}
|
||||
|
||||
Diff (query pattern example)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- let limit_i64 = limit as i64;
|
||||
+ let limit_plus_one = (limit + 1) as i64;
|
||||
@@
|
||||
- LIMIT ?4
|
||||
+ LIMIT ?4
|
||||
@@
|
||||
- rusqlite::params![pq.value, since_ms, project_id, limit_i64],
|
||||
+ rusqlite::params![pq.value, since_ms, project_id, limit_plus_one],
|
||||
@@
|
||||
- Ok(ExpertResult {
|
||||
+ let truncated = experts.len() > limit;
|
||||
+ let experts = experts.into_iter().take(limit).collect();
|
||||
+ Ok(ExpertResult {
|
||||
path_query: path.to_string(),
|
||||
experts,
|
||||
+ truncated,
|
||||
})
|
||||
|
||||
|
||||
Human output hint:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
if r.experts.is_empty() { ... }
|
||||
+ if r.truncated {
|
||||
+ println!(" {}", style("(showing first -n; rerun with a higher --limit)").dim());
|
||||
+ }
|
||||
|
||||
|
||||
Robot output field:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
fn expert_to_json(r: &ExpertResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"path_query": r.path_query,
|
||||
+ "truncated": r.truncated,
|
||||
"experts": ...
|
||||
})
|
||||
}
|
||||
|
||||
6) Overlap merge hot loop: avoid repeated HashSet rebuild per row
|
||||
Why
|
||||
|
||||
This line is expensive in a UNION result with many rows:
|
||||
|
||||
rust
|
||||
Copy code
|
||||
let mut set: HashSet<String> = entry.mr_refs.drain(..).collect();
|
||||
|
||||
|
||||
It reallocates and rehashes every time.
|
||||
|
||||
Fix: store an accumulator with HashSet during merge, convert once at end.
|
||||
|
||||
Diff (internal accumulator)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
- let mut user_map: HashMap<String, OverlapUser> = HashMap::new();
|
||||
+ struct OverlapAcc {
|
||||
+ username: String,
|
||||
+ author_touch_count: u32,
|
||||
+ review_touch_count: u32,
|
||||
+ touch_count: u32,
|
||||
+ last_touch_at: i64,
|
||||
+ mr_refs: HashSet<String>,
|
||||
+ }
|
||||
+ let mut user_map: HashMap<String, OverlapAcc> = HashMap::new();
|
||||
@@
|
||||
- let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapUser {
|
||||
+ let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapAcc {
|
||||
username: username.clone(),
|
||||
author_touch_count: 0,
|
||||
review_touch_count: 0,
|
||||
touch_count: 0,
|
||||
last_touch_at: 0,
|
||||
- mr_refs: Vec::new(),
|
||||
+ mr_refs: HashSet::new(),
|
||||
});
|
||||
@@
|
||||
- let mut set: HashSet<String> = entry.mr_refs.drain(..).collect();
|
||||
- for r in mr_refs { set.insert(r); }
|
||||
- entry.mr_refs = set.into_iter().collect();
|
||||
+ for r in mr_refs { entry.mr_refs.insert(r); }
|
||||
@@
|
||||
- let mut users: Vec<OverlapUser> = user_map.into_values().collect();
|
||||
+ let mut users: Vec<OverlapUser> = user_map.into_values().map(|a| {
|
||||
+ let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
|
||||
+ mr_refs.sort();
|
||||
+ OverlapUser {
|
||||
+ username: a.username,
|
||||
+ author_touch_count: a.author_touch_count,
|
||||
+ review_touch_count: a.review_touch_count,
|
||||
+ touch_count: a.touch_count,
|
||||
+ last_touch_at: a.last_touch_at,
|
||||
+ mr_refs,
|
||||
+ }
|
||||
+ }).collect();
|
||||
|
||||
7) Tests to lock these behaviors
|
||||
Add tests (high value)
|
||||
|
||||
dotless subdir file uses DB probe → exact match
|
||||
|
||||
self-review exclusion prevents MR author showing up as reviewer
|
||||
|
||||
deterministic ordering for participants and mr_refs (sort)
|
||||
|
||||
Diff (test additions outline)
|
||||
diff
|
||||
Copy code
|
||||
@@
|
||||
#[test]
|
||||
+ fn test_build_path_query_dotless_subdir_file_uses_probe() {
|
||||
+ let conn = setup_test_db();
|
||||
+ insert_project(&conn, 1, "team/backend");
|
||||
+ insert_mr(&conn, 1, 1, 100, "author_a", "opened");
|
||||
+ insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||
+ insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/Dockerfile", "note");
|
||||
+
|
||||
+ let pq = build_path_query(&conn, "src/Dockerfile").unwrap();
|
||||
+ assert_eq!(pq.value, "src/Dockerfile");
|
||||
+ assert!(!pq.is_prefix);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_overlap_excludes_self_review_notes() {
|
||||
+ let conn = setup_test_db();
|
||||
+ insert_project(&conn, 1, "team/backend");
|
||||
+ insert_mr(&conn, 1, 1, 100, "author_a", "opened");
|
||||
+ insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||
+ // author_a comments on their own MR diff
|
||||
+ insert_diffnote(&conn, 1, 1, 1, "author_a", "src/auth/login.rs", "clarification");
|
||||
+
|
||||
+ let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||
+ let u = result.users.iter().find(|u| u.username == "author_a");
|
||||
+ // should not be credited as reviewer touch
|
||||
+ assert!(u.map(|x| x.review_touch_count).unwrap_or(0) == 0);
|
||||
+ }
|
||||
|
||||
Net effect
|
||||
|
||||
Correctness: fixes dotless subdir files + self-review pollution.
|
||||
|
||||
Signal quality: Expert ranking becomes harder to game by comment volume.
|
||||
|
||||
Robot reproducibility: deterministic ordering + explicit truncation.
|
||||
|
||||
Performance: avoids rehash loops in overlap merges; path probe uses indexed equality.
|
||||
|
||||
If you want one “single best” change: #1 (DB probe exact-match) is the most likely to prevent confusing “why is this empty?” behavior without adding any user-facing complexity.
|
||||
@@ -1,353 +0,0 @@
|
||||
Below are the highest-leverage revisions I’d make to iteration 6 to improve correctness (multi-project edge cases), robot-mode reliability (bounded payloads + truncation), and signal quality—without changing the fundamental scope (still pure SQL over existing tables).
|
||||
|
||||
1) Make build_path_query project-aware and two-way probe (exact and prefix)
|
||||
Why
|
||||
|
||||
Your DB probe currently answers: “does this exact file exist anywhere in DiffNotes?” That can misclassify in a project-scoped run:
|
||||
|
||||
Path exists as a dotless file in Project A → probe returns true
|
||||
|
||||
User runs -p Project B where the path is a directory (or different shape) → you switch to exact, return empty, and miss valid prefix hits.
|
||||
|
||||
Also, you still have a minor heuristic fragility for dot directories when the user omits trailing / (e.g., .github/workflows): last segment has a dot → you treat as file unless forced dir.
|
||||
|
||||
Revision
|
||||
|
||||
Thread project_id into build_path_query(conn, path, project_id)
|
||||
|
||||
Probe exact first (scoped), then probe prefix (scoped)
|
||||
|
||||
Only fall back to heuristics if both probes fail
|
||||
|
||||
This keeps “static SQL, no dynamic assembly,” and costs at most 2 indexed existence queries per invocation.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
- fn build_path_query(conn: &Connection, path: &str) -> Result<PathQuery> {
|
||||
+ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> Result<PathQuery> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||
let is_root = !trimmed.contains('/');
|
||||
let forced_dir = path.ends_with('/');
|
||||
- let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||
+ // Heuristic is now only a fallback; probes decide first.
|
||||
+ let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||
|
||||
- let exact_exists = if !looks_like_file && !forced_dir {
|
||||
- conn.query_row(
|
||||
- "SELECT 1 FROM notes
|
||||
- WHERE note_type = 'DiffNote'
|
||||
- AND is_system = 0
|
||||
- AND position_new_path = ?1
|
||||
- LIMIT 1",
|
||||
- rusqlite::params![trimmed],
|
||||
- |_| Ok(()),
|
||||
- )
|
||||
- .is_ok()
|
||||
- } else {
|
||||
- false
|
||||
- };
|
||||
+ // Probe 1: exact file exists (scoped)
|
||||
+ let exact_exists = conn.query_row(
|
||||
+ "SELECT 1 FROM notes
|
||||
+ WHERE note_type = 'DiffNote'
|
||||
+ AND is_system = 0
|
||||
+ AND position_new_path = ?1
|
||||
+ AND (?2 IS NULL OR project_id = ?2)
|
||||
+ LIMIT 1",
|
||||
+ rusqlite::params![trimmed, project_id],
|
||||
+ |_| Ok(()),
|
||||
+ ).is_ok();
|
||||
+
|
||||
+ // Probe 2: directory prefix exists (scoped)
|
||||
+ let prefix_exists = if !forced_dir {
|
||||
+ let escaped = escape_like(trimmed);
|
||||
+ let pat = format!("{escaped}/%");
|
||||
+ conn.query_row(
|
||||
+ "SELECT 1 FROM notes
|
||||
+ WHERE note_type = 'DiffNote'
|
||||
+ AND is_system = 0
|
||||
+ AND position_new_path LIKE ?1 ESCAPE '\\'
|
||||
+ AND (?2 IS NULL OR project_id = ?2)
|
||||
+ LIMIT 1",
|
||||
+ rusqlite::params![pat, project_id],
|
||||
+ |_| Ok(()),
|
||||
+ ).is_ok()
|
||||
+ } else { false };
|
||||
|
||||
- let is_file = looks_like_file || exact_exists;
|
||||
+ // Forced directory always wins; otherwise: exact > prefix > heuristic
|
||||
+ let is_file = if forced_dir { false }
|
||||
+ else if exact_exists { true }
|
||||
+ else if prefix_exists { false }
|
||||
+ else { looks_like_file };
|
||||
|
||||
if is_file {
|
||||
Ok(PathQuery { value: trimmed.to_string(), is_prefix: false })
|
||||
} else {
|
||||
let escaped = escape_like(trimmed);
|
||||
Ok(PathQuery { value: format!("{escaped}/%"), is_prefix: true })
|
||||
}
|
||||
}
|
||||
@@
|
||||
- let pq = build_path_query(conn, path)?;
|
||||
+ let pq = build_path_query(conn, path, project_id)?;
|
||||
|
||||
|
||||
Add test coverage for the multi-project misclassification case:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
#[test]
|
||||
fn test_build_path_query_dotless_subdir_file_uses_db_probe() {
|
||||
@@
|
||||
- let pq = build_path_query(&conn, "src/Dockerfile").unwrap();
|
||||
+ let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
|
||||
@@
|
||||
- let pq2 = build_path_query(&conn2, "src/Dockerfile").unwrap();
|
||||
+ let pq2 = build_path_query(&conn2, "src/Dockerfile", None).unwrap();
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_build_path_query_probe_is_project_scoped() {
|
||||
+ // Path exists as a dotless file in project 1; project 2 should not
|
||||
+ // treat it as an exact file unless it exists there too.
|
||||
+ let conn = setup_test_db();
|
||||
+ insert_project(&conn, 1, "team/a");
|
||||
+ insert_project(&conn, 2, "team/b");
|
||||
+ insert_mr(&conn, 1, 1, 10, "author_a", "opened");
|
||||
+ insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||
+ insert_diffnote(&conn, 1, 1, 1, "rev", "infra/Makefile", "note");
|
||||
+
|
||||
+ let pq_scoped = build_path_query(&conn, "infra/Makefile", Some(2)).unwrap();
|
||||
+ assert!(pq_scoped.is_prefix); // should fall back to prefix in project 2
|
||||
+ }
|
||||
|
||||
2) Bound robot payload sizes for participants and mr_refs (with totals + truncation)
|
||||
Why
|
||||
|
||||
mr_refs and participants can become unbounded arrays in robot mode, which is a real operational hazard:
|
||||
|
||||
huge JSON → slow, noisy diffs, brittle downstream pipelines
|
||||
|
||||
potential SQLite group_concat truncation becomes invisible (and you can’t distinguish “no refs” vs “refs truncated”)
|
||||
|
||||
Revision
|
||||
|
||||
Introduce hard caps and explicit metadata:
|
||||
|
||||
participants_total, participants_truncated
|
||||
|
||||
mr_refs_total, mr_refs_truncated
|
||||
|
||||
This is not scope creep—it’s defensive output hygiene.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
pub struct ActiveDiscussion {
|
||||
@@
|
||||
pub participants: Vec<String>,
|
||||
+ pub participants_total: u32,
|
||||
+ pub participants_truncated: bool,
|
||||
}
|
||||
@@
|
||||
pub struct OverlapUser {
|
||||
@@
|
||||
pub mr_refs: Vec<String>,
|
||||
+ pub mr_refs_total: u32,
|
||||
+ pub mr_refs_truncated: bool,
|
||||
}
|
||||
|
||||
|
||||
Implementation sketch (Rust-side, deterministic):
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
fn query_active(...) -> Result<ActiveResult> {
|
||||
+ const MAX_PARTICIPANTS: usize = 50;
|
||||
@@
|
||||
- participants.sort();
|
||||
+ participants.sort();
|
||||
+ let participants_total = participants.len() as u32;
|
||||
+ let participants_truncated = participants.len() > MAX_PARTICIPANTS;
|
||||
+ if participants_truncated {
|
||||
+ participants.truncate(MAX_PARTICIPANTS);
|
||||
+ }
|
||||
@@
|
||||
Ok(ActiveDiscussion {
|
||||
@@
|
||||
participants,
|
||||
+ participants_total,
|
||||
+ participants_truncated,
|
||||
})
|
||||
@@
|
||||
fn query_overlap(...) -> Result<OverlapResult> {
|
||||
+ const MAX_MR_REFS_PER_USER: usize = 50;
|
||||
@@
|
||||
.map(|a| {
|
||||
let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
|
||||
mr_refs.sort();
|
||||
+ let mr_refs_total = mr_refs.len() as u32;
|
||||
+ let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
|
||||
+ if mr_refs_truncated {
|
||||
+ mr_refs.truncate(MAX_MR_REFS_PER_USER);
|
||||
+ }
|
||||
OverlapUser {
|
||||
@@
|
||||
mr_refs,
|
||||
+ mr_refs_total,
|
||||
+ mr_refs_truncated,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Update robot JSON accordingly:
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
fn active_to_json(r: &ActiveResult) -> serde_json::Value {
|
||||
@@
|
||||
"participants": d.participants,
|
||||
+ "participants_total": d.participants_total,
|
||||
+ "participants_truncated": d.participants_truncated,
|
||||
}))
|
||||
@@
|
||||
fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
|
||||
@@
|
||||
"mr_refs": u.mr_refs,
|
||||
+ "mr_refs_total": u.mr_refs_total,
|
||||
+ "mr_refs_truncated": u.mr_refs_truncated,
|
||||
}))
|
||||
|
||||
|
||||
Also update robot-docs manifest schema snippet for who.active.discussions[] and who.overlap.users[].
|
||||
|
||||
3) Add truncation metadata to Workload sections (same LIMIT+1 pattern)
|
||||
Why
|
||||
|
||||
Workload is the mode most likely to be consumed by agents, and right now it has silent truncation (each section is LIMIT N with no signal). Your plan already treats truncation as a first-class contract elsewhere; Workload should match.
|
||||
|
||||
Revision
|
||||
|
||||
For each workload query:
|
||||
|
||||
request LIMIT + 1
|
||||
|
||||
set *_truncated booleans
|
||||
|
||||
trim to requested limit
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
pub struct WorkloadResult {
|
||||
pub username: String,
|
||||
pub assigned_issues: Vec<WorkloadIssue>,
|
||||
pub authored_mrs: Vec<WorkloadMr>,
|
||||
pub reviewing_mrs: Vec<WorkloadMr>,
|
||||
pub unresolved_discussions: Vec<WorkloadDiscussion>,
|
||||
+ pub assigned_issues_truncated: bool,
|
||||
+ pub authored_mrs_truncated: bool,
|
||||
+ pub reviewing_mrs_truncated: bool,
|
||||
+ pub unresolved_discussions_truncated: bool,
|
||||
}
|
||||
|
||||
|
||||
And in JSON include the booleans (plus you already have summary.counts).
|
||||
|
||||
This is mechanically repetitive but extremely valuable for automation.
|
||||
|
||||
4) Rename “Last Active” → “Last Seen” for Expert/Overlap
|
||||
Why
|
||||
|
||||
For “author” rows, the timestamp is derived from review activity on their MR (via MAX(n.created_at)), not necessarily that person’s direct action. Calling that “active” is semantically misleading. “Last seen” is accurate across both reviewer+author branches.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
pub struct Expert {
|
||||
@@
|
||||
- pub last_active_ms: i64,
|
||||
+ pub last_seen_ms: i64,
|
||||
}
|
||||
@@
|
||||
pub struct OverlapUser {
|
||||
@@
|
||||
- pub last_touch_at: i64,
|
||||
+ pub last_seen_at: i64,
|
||||
@@
|
||||
fn print_expert_human(...) {
|
||||
@@
|
||||
- style("Last Active").bold(),
|
||||
+ style("Last Seen").bold(),
|
||||
@@
|
||||
- style(format_relative_time(expert.last_active_ms)).dim(),
|
||||
+ style(format_relative_time(expert.last_seen_ms)).dim(),
|
||||
|
||||
|
||||
(Keep internal SQL aliases consistent: last_seen_at everywhere.)
|
||||
|
||||
5) Make MR state filtering consistent in Expert/Overlap reviewer branches
|
||||
Why
|
||||
|
||||
You already restrict Overlap author branch to opened|merged, but reviewer branches can include closed/unmerged noise. Consistency improves signal quality and can reduce scan churn.
|
||||
|
||||
Low-risk revision: apply the same state filter to reviewer branches (Expert + Overlap). You can keep “closed” excluded by default without adding new flags.
|
||||
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
@@
|
||||
- AND n.created_at >= ?2
|
||||
+ AND m.state IN ('opened','merged')
|
||||
+ AND n.created_at >= ?2
|
||||
|
||||
|
||||
This is a semantic choice; if you later want archaeology across closed/unmerged, that belongs in a separate mode/flag, but I would not add it now.
|
||||
|
||||
6) Add a design principle for bounded outputs (aligns with robot-first reproducibility)
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
10. **Truncation transparency.** Result types carry a `truncated: bool` flag...
|
||||
+11. **Bounded payloads.** Robot JSON must never emit unbounded arrays (participants, refs).
|
||||
+ Large list fields are capped with `*_total` + `*_truncated` so agents can page/retry.
|
||||
|
||||
Consolidated plan metadata bump (Iteration 7)
|
||||
diff
|
||||
Copy code
|
||||
diff --git a/who-command-design.md b/who-command-design.md
|
||||
@@
|
||||
-iteration: 6
|
||||
+iteration: 7
|
||||
updated: 2026-02-07
|
||||
|
||||
Net effect (what you get)
|
||||
|
||||
Correct path classification under -p scoping (no cross-project probe leakage)
|
||||
|
||||
Deterministic + bounded robot payloads (no giant JSON surprises)
|
||||
|
||||
Uniform truncation contract across all modes (Workload no longer silently truncates)
|
||||
|
||||
Clearer semantics (“Last Seen” avoids misinterpretation)
|
||||
|
||||
Cleaner signals (reviewer branches ignore closed/unmerged by default)
|
||||
|
||||
If you want, I can also produce a second diff that updates the robot-docs schema block and the Verification EXPLAIN expectations to reflect the new probe queries and the state filter.
|
||||
24
migrations/022_notes_query_index.sql
Normal file
24
migrations/022_notes_query_index.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Migration 022: Composite query indexes for notes + author_id column
|
||||
-- Optimizes author-scoped and project-scoped date-range queries on notes.
|
||||
-- Adds discussion JOIN indexes and immutable author identity column.
|
||||
|
||||
-- Composite index for author-scoped queries (who command, notes --author)
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user_created
|
||||
ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC)
|
||||
WHERE is_system = 0;
|
||||
|
||||
-- Composite index for project-scoped date-range queries
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_project_created
|
||||
ON notes(project_id, created_at DESC, id DESC)
|
||||
WHERE is_system = 0;
|
||||
|
||||
-- Discussion JOIN indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);
|
||||
|
||||
-- Immutable author identity column (GitLab numeric user ID)
|
||||
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (22, strftime('%s', 'now') * 1000, '022_notes_query_index');
|
||||
156
migrations/024_note_documents.sql
Normal file
156
migrations/024_note_documents.sql
Normal file
@@ -0,0 +1,156 @@
|
||||
-- Migration 024: Add 'note' source_type to documents and dirty_sources
|
||||
-- SQLite does not support ALTER CONSTRAINT, so we use the table-rebuild pattern.
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Rebuild dirty_sources with updated CHECK constraint
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE dirty_sources_new (
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||
source_id INTEGER NOT NULL,
|
||||
queued_at INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER,
|
||||
PRIMARY KEY(source_type, source_id)
|
||||
);
|
||||
|
||||
INSERT INTO dirty_sources_new SELECT * FROM dirty_sources;
|
||||
DROP TABLE dirty_sources;
|
||||
ALTER TABLE dirty_sources_new RENAME TO dirty_sources;
|
||||
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Rebuild documents with updated CHECK constraint
|
||||
-- ============================================================
|
||||
|
||||
-- 2a. Backup junction table data
|
||||
CREATE TEMP TABLE _doc_labels_backup AS SELECT * FROM document_labels;
|
||||
CREATE TEMP TABLE _doc_paths_backup AS SELECT * FROM document_paths;
|
||||
|
||||
-- 2b. Drop all triggers that reference documents
|
||||
DROP TRIGGER IF EXISTS documents_ai;
|
||||
DROP TRIGGER IF EXISTS documents_ad;
|
||||
DROP TRIGGER IF EXISTS documents_au;
|
||||
DROP TRIGGER IF EXISTS documents_embeddings_ad;
|
||||
|
||||
-- 2c. Drop junction tables (they have FK references to documents)
|
||||
DROP TABLE IF EXISTS document_labels;
|
||||
DROP TABLE IF EXISTS document_paths;
|
||||
|
||||
-- 2d. Create new documents table with 'note' in CHECK constraint
|
||||
CREATE TABLE documents_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||
source_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
author_username TEXT,
|
||||
label_names TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
content_text TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
labels_hash TEXT NOT NULL DEFAULT '',
|
||||
paths_hash TEXT NOT NULL DEFAULT '',
|
||||
is_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
truncated_reason TEXT CHECK (
|
||||
truncated_reason IN (
|
||||
'token_limit_middle_drop','single_note_oversized','first_last_oversized',
|
||||
'hard_cap_oversized'
|
||||
)
|
||||
OR truncated_reason IS NULL
|
||||
),
|
||||
UNIQUE(source_type, source_id)
|
||||
);
|
||||
|
||||
-- 2e. Copy all existing data
|
||||
INSERT INTO documents_new SELECT * FROM documents;
|
||||
|
||||
-- 2f. Swap tables
|
||||
DROP TABLE documents;
|
||||
ALTER TABLE documents_new RENAME TO documents;
|
||||
|
||||
-- 2g. Recreate all indexes on documents
|
||||
CREATE INDEX idx_documents_project_updated ON documents(project_id, updated_at);
|
||||
CREATE INDEX idx_documents_author ON documents(author_username);
|
||||
CREATE INDEX idx_documents_source ON documents(source_type, source_id);
|
||||
CREATE INDEX idx_documents_hash ON documents(content_hash);
|
||||
|
||||
-- 2h. Recreate junction tables
|
||||
CREATE TABLE document_labels (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
label_name TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, label_name)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_document_labels_label ON document_labels(label_name);
|
||||
|
||||
CREATE TABLE document_paths (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, path)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_document_paths_path ON document_paths(path);
|
||||
|
||||
-- 2i. Restore junction table data from backups
|
||||
INSERT INTO document_labels SELECT * FROM _doc_labels_backup;
|
||||
INSERT INTO document_paths SELECT * FROM _doc_paths_backup;
|
||||
|
||||
-- 2j. Recreate FTS triggers (from migration 008)
|
||||
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(rowid, title, content_text)
|
||||
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_au AFTER UPDATE ON documents
|
||||
WHEN old.title IS NOT new.title OR old.content_text != new.content_text
|
||||
BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||
INSERT INTO documents_fts(rowid, title, content_text)
|
||||
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||
END;
|
||||
|
||||
-- 2k. Recreate embeddings cleanup trigger (from migration 009)
|
||||
CREATE TRIGGER documents_embeddings_ad AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM embeddings
|
||||
WHERE rowid >= old.id * 1000
|
||||
AND rowid < (old.id + 1) * 1000;
|
||||
END;
|
||||
|
||||
-- 2l. Rebuild FTS index to ensure consistency after table swap
|
||||
INSERT INTO documents_fts(documents_fts) VALUES('rebuild');
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Defense triggers: clean up documents when notes are
|
||||
-- deleted or flipped to system notes
|
||||
-- ============================================================
|
||||
|
||||
CREATE TRIGGER notes_ad_cleanup AFTER DELETE ON notes
|
||||
WHEN old.is_system = 0
|
||||
BEGIN
|
||||
DELETE FROM documents WHERE source_type = 'note' AND source_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_au_system_cleanup AFTER UPDATE OF is_system ON notes
|
||||
WHEN NEW.is_system = 1 AND OLD.is_system = 0
|
||||
BEGIN
|
||||
DELETE FROM documents WHERE source_type = 'note' AND source_id = OLD.id;
|
||||
END;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Drop temp backup tables
|
||||
-- ============================================================
|
||||
|
||||
DROP TABLE IF EXISTS _doc_labels_backup;
|
||||
DROP TABLE IF EXISTS _doc_paths_backup;
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (24, strftime('%s', 'now') * 1000, '024_note_documents');
|
||||
11
migrations/025_note_dirty_backfill.sql
Normal file
11
migrations/025_note_dirty_backfill.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Backfill existing non-system notes into dirty queue for document generation.
|
||||
-- Only seeds notes that don't already have documents and aren't already queued.
|
||||
INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
SELECT 'note', n.id, CAST(strftime('%s', 'now') AS INTEGER) * 1000
|
||||
FROM notes n
|
||||
LEFT JOIN documents d ON d.source_type = 'note' AND d.source_id = n.id
|
||||
WHERE n.is_system = 0 AND d.id IS NULL
|
||||
ON CONFLICT(source_type, source_id) DO NOTHING;
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (25, strftime('%s', 'now') * 1000, '025_note_dirty_backfill');
|
||||
23
migrations/026_scoring_indexes.sql
Normal file
23
migrations/026_scoring_indexes.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Indexes for time-decay expert scoring: dual-path matching and reviewer participation.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_old_path_author
|
||||
ON notes(position_old_path, author_username, created_at)
|
||||
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr
|
||||
ON mr_file_changes(old_path, project_id, merge_request_id)
|
||||
WHERE old_path IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
|
||||
ON mr_file_changes(new_path, project_id, merge_request_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
||||
ON notes(discussion_id, author_username, created_at)
|
||||
WHERE note_type = 'DiffNote' AND is_system = 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||
ON notes(position_old_path, project_id, created_at)
|
||||
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (26, strftime('%s', 'now') * 1000, '026_scoring_indexes');
|
||||
23
migrations/027_surgical_sync_runs.sql
Normal file
23
migrations/027_surgical_sync_runs.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Migration 027: Extend sync_runs for surgical sync observability
|
||||
-- Adds mode/phase tracking and surgical-specific counters.
|
||||
|
||||
ALTER TABLE sync_runs ADD COLUMN mode TEXT;
|
||||
ALTER TABLE sync_runs ADD COLUMN phase TEXT;
|
||||
ALTER TABLE sync_runs ADD COLUMN surgical_iids_json TEXT;
|
||||
ALTER TABLE sync_runs ADD COLUMN issues_fetched INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN mrs_fetched INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN issues_ingested INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN mrs_ingested INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN skipped_stale INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN docs_regenerated INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN docs_embedded INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started
|
||||
ON sync_runs(mode, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started
|
||||
ON sync_runs(status, phase, started_at DESC);
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (27, strftime('%s', 'now') * 1000, '027_surgical_sync_runs');
|
||||
58
migrations/028_discussions_mr_fk.sql
Normal file
58
migrations/028_discussions_mr_fk.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- Migration 028: Add FK constraint on discussions.merge_request_id
|
||||
-- Schema version: 28
|
||||
-- Fixes missing foreign key that causes orphaned discussions when MRs are deleted
|
||||
|
||||
-- SQLite doesn't support ALTER TABLE ADD CONSTRAINT, so we must recreate the table.
|
||||
|
||||
-- Step 1: Create new table with the FK constraint
|
||||
CREATE TABLE discussions_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_discussion_id TEXT NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,
|
||||
merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE, -- FK was missing!
|
||||
noteable_type TEXT NOT NULL CHECK (noteable_type IN ('Issue', 'MergeRequest')),
|
||||
individual_note INTEGER NOT NULL DEFAULT 0,
|
||||
first_note_at INTEGER,
|
||||
last_note_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0,
|
||||
raw_payload_id INTEGER REFERENCES raw_payloads(id), -- Added in migration 004
|
||||
CHECK (
|
||||
(noteable_type = 'Issue' AND issue_id IS NOT NULL AND merge_request_id IS NULL) OR
|
||||
(noteable_type = 'MergeRequest' AND merge_request_id IS NOT NULL AND issue_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Step 2: Copy data (only rows with valid FK references to avoid constraint violations)
|
||||
INSERT INTO discussions_new
|
||||
SELECT d.* FROM discussions d
|
||||
WHERE (d.merge_request_id IS NULL OR EXISTS (SELECT 1 FROM merge_requests m WHERE m.id = d.merge_request_id));
|
||||
|
||||
-- Step 3: Drop old table and rename
|
||||
DROP TABLE discussions;
|
||||
ALTER TABLE discussions_new RENAME TO discussions;
|
||||
|
||||
-- Step 4: Recreate ALL indexes that were on the discussions table
|
||||
-- From migration 002 (original table)
|
||||
CREATE UNIQUE INDEX uq_discussions_project_discussion_id ON discussions(project_id, gitlab_discussion_id);
|
||||
CREATE INDEX idx_discussions_issue ON discussions(issue_id);
|
||||
CREATE INDEX idx_discussions_mr ON discussions(merge_request_id);
|
||||
CREATE INDEX idx_discussions_last_note ON discussions(last_note_at);
|
||||
-- From migration 003 (orphan detection)
|
||||
CREATE INDEX idx_discussions_last_seen ON discussions(last_seen_at);
|
||||
-- From migration 006 (MR indexes)
|
||||
CREATE INDEX idx_discussions_mr_id ON discussions(merge_request_id);
|
||||
CREATE INDEX idx_discussions_mr_resolved ON discussions(merge_request_id, resolved, resolvable);
|
||||
-- From migration 017 (who command indexes)
|
||||
CREATE INDEX idx_discussions_unresolved_recent ON discussions(project_id, last_note_at) WHERE resolvable = 1 AND resolved = 0;
|
||||
CREATE INDEX idx_discussions_unresolved_recent_global ON discussions(last_note_at) WHERE resolvable = 1 AND resolved = 0;
|
||||
-- From migration 019 (list performance)
|
||||
CREATE INDEX idx_discussions_issue_resolved ON discussions(issue_id, resolvable, resolved);
|
||||
-- From migration 022 (notes query optimization)
|
||||
CREATE INDEX idx_discussions_issue_id ON discussions(issue_id);
|
||||
|
||||
-- Record migration
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (28, strftime('%s', 'now') * 1000, 'Add FK constraint on discussions.merge_request_id');
|
||||
652
plans/gitlab-todos-notifications-integration.md
Normal file
652
plans/gitlab-todos-notifications-integration.md
Normal file
@@ -0,0 +1,652 @@
|
||||
---
|
||||
plan: true
|
||||
title: "GitLab TODOs Integration"
|
||||
status: proposed
|
||||
iteration: 4
|
||||
target_iterations: 4
|
||||
beads_revision: 1
|
||||
related_plans: []
|
||||
created: 2026-02-23
|
||||
updated: 2026-02-26
|
||||
audit_revision: 4
|
||||
---
|
||||
|
||||
# GitLab TODOs Integration
|
||||
|
||||
## Summary
|
||||
|
||||
Add GitLab TODO support to lore. Todos are fetched during sync, stored locally, and surfaced through a standalone `lore todos` command and integration into the `lore me` dashboard.
|
||||
|
||||
**Scope:** Read-only. No mark-as-done operations.
|
||||
|
||||
---
|
||||
|
||||
## Workflows
|
||||
|
||||
### Workflow 1: Morning Triage (Human)
|
||||
|
||||
1. User runs `lore me` to see personal dashboard
|
||||
2. Summary header shows "5 pending todos" alongside issue/MR counts
|
||||
3. Todos section groups items: 2 Assignments, 2 Mentions, 1 Approval Required
|
||||
4. User scans Assignments — sees issue #42 assigned by @manager
|
||||
5. User runs `lore todos` for full detail with body snippets
|
||||
6. User clicks target URL to address highest-priority item
|
||||
7. After marking done in GitLab, next `lore sync` removes it locally
|
||||
|
||||
### Workflow 2: Agent Polling (Robot Mode)
|
||||
|
||||
1. Agent runs `lore --robot health` as pre-flight check
|
||||
2. Agent runs `lore --robot me --fields minimal` for dashboard
|
||||
3. Agent extracts `pending_todo_count` from summary — if 0, skip todos
|
||||
4. If count > 0, agent runs `lore --robot todos`
|
||||
5. Agent iterates `data.todos[]`, filtering by `action` type
|
||||
6. Agent prioritizes `approval_required` and `build_failed` for immediate attention
|
||||
7. Agent logs external todos (`is_external: true`) for manual review
|
||||
|
||||
### Workflow 3: Cross-Project Visibility
|
||||
|
||||
1. User is mentioned in a project they don't sync (e.g., company-wide repo)
|
||||
2. `lore sync` fetches the todo anyway (account-wide fetch)
|
||||
3. `lore todos` shows item with `[external]` indicator and project path
|
||||
4. User can still click target URL to view in GitLab
|
||||
5. Target title may be unavailable — graceful fallback to "Untitled"
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Behavioral contract. Each AC is a single testable statement.
|
||||
|
||||
### Storage
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-1 | Todos are persisted locally in SQLite |
|
||||
| AC-2 | Each todo is uniquely identified by its GitLab todo ID |
|
||||
| AC-3 | Todos from non-synced projects are stored with their project path |
|
||||
|
||||
### Sync
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-4 | `lore sync` fetches all pending todos from GitLab |
|
||||
| AC-5 | Sync fetches todos account-wide, not per-project |
|
||||
| AC-6 | Todos marked done in GitLab are removed locally on next sync |
|
||||
| AC-7 | Transient sync errors do not delete valid local todos |
|
||||
| AC-8 | `lore sync --no-todos` skips todo fetching |
|
||||
| AC-9 | Sync logs todo statistics (fetched, inserted, updated, deleted) |
|
||||
|
||||
### `lore todos` Command
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-10 | `lore todos` displays all pending todos |
|
||||
| AC-11 | Todos are grouped by action type: Assignments, Mentions, Approvals, Build Issues |
|
||||
| AC-12 | Each todo shows: target title, project path, author, age |
|
||||
| AC-13 | Non-synced project todos display `[external]` indicator |
|
||||
| AC-14 | `lore todos --limit N` limits output to N todos |
|
||||
| AC-15 | `lore --robot todos` returns JSON with standard `{ok, data, meta}` envelope |
|
||||
| AC-16 | `lore --robot todos --fields minimal` returns reduced field set |
|
||||
| AC-17 | `todo` and `td` are recognized as aliases for `todos` |
|
||||
|
||||
### `lore me` Integration
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-18 | `lore me` summary includes pending todo count |
|
||||
| AC-19 | `lore me` includes a todos section in the full dashboard |
|
||||
| AC-20 | `lore me --todos` shows only the todos section |
|
||||
| AC-21 | Todos are NOT filtered by `--project` flag (always account-wide) |
|
||||
| AC-22 | Warning is displayed if `--project` is passed with `--todos` |
|
||||
| AC-23 | Todo events appear in the activity feed for local entities |
|
||||
|
||||
### Action Types
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-24 | Core actions are displayed: assigned, mentioned, directly_addressed, approval_required, build_failed, unmergeable |
|
||||
| AC-25 | Niche actions are stored but not displayed: merge_train_removed, member_access_requested, marked |
|
||||
|
||||
### Attention State
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-26 | Todos do not affect attention state calculation |
|
||||
| AC-27 | Todos do not appear in "since last check" cursor-based inbox |
|
||||
|
||||
### Error Handling
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-28 | 403 Forbidden on todos API logs warning and continues sync |
|
||||
| AC-29 | 429 Rate Limited respects Retry-After header |
|
||||
| AC-30 | Malformed todo JSON logs warning, skips that item, and disables purge for that sync |
|
||||
|
||||
### Documentation
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-31 | `lore todos` appears in CLI help |
|
||||
| AC-32 | `lore robot-docs` includes todos schema |
|
||||
| AC-33 | CLAUDE.md documents the todos command |
|
||||
|
||||
### Quality
|
||||
|
||||
| ID | Behavior |
|
||||
|----|----------|
|
||||
| AC-34 | All quality gates pass: check, clippy, fmt, test |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Designed to fulfill the acceptance criteria above.
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── gitlab/
|
||||
│ ├── client.rs # fetch_todos() method (AC-4, AC-5)
|
||||
│ └── types.rs # GitLabTodo struct
|
||||
├── ingestion/
|
||||
│ └── todos.rs # sync_todos(), purge-safe deletion (AC-6, AC-7)
|
||||
├── cli/commands/
|
||||
│ ├── todos.rs # lore todos command (AC-10-17)
|
||||
│ └── me/
|
||||
│ ├── types.rs # MeTodo, extend MeSummary (AC-18)
|
||||
│ └── queries.rs # query_todos() (AC-19, AC-23)
|
||||
└── core/
|
||||
└── db.rs # Migration 028 (AC-1, AC-2, AC-3)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
GitLab API Local SQLite CLI Output
|
||||
─────────── ──────────── ──────────
|
||||
GET /api/v4/todos → todos table → lore todos
|
||||
(account-wide) (purge-safe sync) lore me --todos
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
| Decision | Rationale | ACs |
|
||||
|----------|-----------|-----|
|
||||
| Account-wide fetch | GitLab todos API is user-scoped, not project-scoped | AC-5, AC-21 |
|
||||
| Purge-safe deletion | Transient errors should not delete valid data | AC-7 |
|
||||
| Separate from attention | Todos are notifications, not engagement signals | AC-26, AC-27 |
|
||||
| Store all actions, display core | Future-proofs for new action types | AC-24, AC-25 |
|
||||
|
||||
### Existing Code to Extend
|
||||
|
||||
| Type | Location | Extension |
|
||||
|------|----------|-----------|
|
||||
| `MeSummary` | `src/cli/commands/me/types.rs` | Add `pending_todo_count` field |
|
||||
| `ActivityEventType` | `src/cli/commands/me/types.rs` | Add `Todo` variant |
|
||||
| `MeDashboard` | `src/cli/commands/me/types.rs` | Add `todos: Vec<MeTodo>` field |
|
||||
| `SyncArgs` | `src/cli/mod.rs` | Add `--no-todos` flag |
|
||||
| `MeArgs` | `src/cli/mod.rs` | Add `--todos` flag |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Specifications
|
||||
|
||||
Each IMP section details HOW to fulfill specific ACs.
|
||||
|
||||
### IMP-1: Database Schema
|
||||
|
||||
**Fulfills:** AC-1, AC-2, AC-3
|
||||
|
||||
**Migration 028:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE todos (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_todo_id INTEGER NOT NULL UNIQUE,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
|
||||
gitlab_project_id INTEGER,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_iid INTEGER,
|
||||
target_url TEXT NOT NULL,
|
||||
target_title TEXT,
|
||||
action_name TEXT NOT NULL,
|
||||
author_id INTEGER,
|
||||
author_username TEXT,
|
||||
body TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
sync_generation INTEGER NOT NULL DEFAULT 0,
|
||||
project_path TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_todos_action_created ON todos(action_name, created_at DESC);
|
||||
CREATE INDEX idx_todos_target ON todos(target_type, target_id);
|
||||
CREATE INDEX idx_todos_created ON todos(created_at DESC);
|
||||
CREATE INDEX idx_todos_sync_gen ON todos(sync_generation);
|
||||
CREATE INDEX idx_todos_gitlab_project ON todos(gitlab_project_id);
|
||||
CREATE INDEX idx_todos_target_lookup ON todos(target_type, project_id, target_iid);
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `project_id` nullable for non-synced projects (AC-3)
|
||||
- `gitlab_project_id` nullable — TODO targets include non-project entities (Namespace, etc.)
|
||||
- No `state` column — we only store pending todos
|
||||
- `sync_generation` enables two-generation grace purge (AC-7)
|
||||
|
||||
---
|
||||
|
||||
### IMP-2: GitLab API Client
|
||||
|
||||
**Fulfills:** AC-4, AC-5
|
||||
|
||||
**Endpoint:** `GET /api/v4/todos?state=pending`
|
||||
|
||||
**Types to add in `src/gitlab/types.rs`:**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitLabTodo {
|
||||
pub id: i64,
|
||||
pub project: Option<GitLabTodoProject>,
|
||||
pub author: Option<GitLabTodoAuthor>,
|
||||
pub action_name: String,
|
||||
pub target_type: String,
|
||||
pub target: Option<GitLabTodoTarget>,
|
||||
pub target_url: String,
|
||||
pub body: Option<String>,
|
||||
pub state: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitLabTodoProject {
|
||||
pub id: i64,
|
||||
pub path_with_namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitLabTodoTarget {
|
||||
pub id: serde_json::Value, // i64 or String (commit SHA)
|
||||
pub iid: Option<i64>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitLabTodoAuthor {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
```
|
||||
|
||||
**Client method in `src/gitlab/client.rs`:**
|
||||
|
||||
```rust
|
||||
pub fn fetch_todos(&self) -> impl Stream<Item = Result<GitLabTodo>> {
|
||||
self.paginate("/api/v4/todos?state=pending")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IMP-3: Sync Pipeline Integration
|
||||
|
||||
**Fulfills:** AC-4, AC-5, AC-6, AC-7, AC-8, AC-9
|
||||
|
||||
**New file: `src/ingestion/todos.rs`**
|
||||
|
||||
**Sync position:** Account-wide step after per-project sync and status enrichment.
|
||||
|
||||
```
|
||||
Sync order:
|
||||
1. Issues (per project)
|
||||
2. MRs (per project)
|
||||
3. Status enrichment (account-wide GraphQL)
|
||||
4. Todos (account-wide REST) ← NEW
|
||||
```
|
||||
|
||||
**Purge-safe deletion pattern:**
|
||||
|
||||
```rust
|
||||
pub struct TodoSyncResult {
|
||||
pub fetched: usize,
|
||||
pub upserted: usize,
|
||||
pub deleted: usize,
|
||||
pub generation: i64,
|
||||
pub purge_allowed: bool,
|
||||
}
|
||||
|
||||
pub fn sync_todos(conn: &Connection, client: &GitLabClient) -> Result<TodoSyncResult> {
|
||||
// 1. Get next generation
|
||||
let generation: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(sync_generation), 0) + 1 FROM todos",
|
||||
[], |r| r.get(0)
|
||||
)?;
|
||||
|
||||
let mut fetched = 0;
|
||||
let mut purge_allowed = true;
|
||||
|
||||
// 2. Fetch and upsert all todos
|
||||
for result in client.fetch_todos()? {
|
||||
match result {
|
||||
Ok(todo) => {
|
||||
upsert_todo_guarded(conn, &todo, generation)?;
|
||||
fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
// Malformed JSON: log warning, skip item, disable purge
|
||||
warn!("Skipping malformed todo: {e}");
|
||||
purge_allowed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Two-generation grace purge: delete only if missing for 2+ consecutive syncs
|
||||
// This protects against pagination drift (new todos inserted during traversal)
|
||||
let deleted = if purge_allowed {
|
||||
conn.execute("DELETE FROM todos WHERE sync_generation < ? - 1", [generation])?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(TodoSyncResult { fetched, upserted: fetched, deleted, generation, purge_allowed })
|
||||
}
|
||||
```
|
||||
|
||||
**Concurrent-safe upsert:**
|
||||
|
||||
```sql
|
||||
INSERT INTO todos (..., sync_generation) VALUES (?, ..., ?)
|
||||
ON CONFLICT(gitlab_todo_id) DO UPDATE SET
|
||||
...,
|
||||
sync_generation = excluded.sync_generation,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE excluded.sync_generation >= todos.sync_generation;
|
||||
```
|
||||
|
||||
**"Success" for purge (all must be true):**
|
||||
- Every page fetch completed without error
|
||||
- Every todo JSON decoded successfully (any decode failure sets `purge_allowed=false`)
|
||||
- Pagination traversal completed (not interrupted)
|
||||
- Response was not 401/403
|
||||
- Zero todos IS valid for purge when above conditions met
|
||||
|
||||
**Two-generation grace purge:**
|
||||
Todos are deleted only if missing for 2 consecutive successful syncs (`sync_generation < current - 1`).
|
||||
This protects against false deletions from pagination drift (new todos inserted during traversal).
|
||||
|
||||
---
|
||||
|
||||
### IMP-4: Project Path Extraction
|
||||
|
||||
**Fulfills:** AC-3, AC-13
|
||||
|
||||
```rust
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
pub fn extract_project_path(url: &str) -> Option<&str> {
|
||||
static RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"https?://[^/]+/(.+?)/-/(?:issues|merge_requests|epics|commits)/")
|
||||
.expect("valid regex")
|
||||
});
|
||||
|
||||
RE.captures(url)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str())
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** Prefer `project.path_with_namespace` from API when available. Fall back to URL extraction for external projects.
|
||||
|
||||
---
|
||||
|
||||
### IMP-5: `lore todos` Command
|
||||
|
||||
**Fulfills:** AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17
|
||||
|
||||
**New file: `src/cli/commands/todos.rs`**
|
||||
|
||||
**Args:**
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
#[command(alias = "todo")]
|
||||
pub struct TodosArgs {
|
||||
#[arg(short = 'n', long)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
```
|
||||
|
||||
**Autocorrect aliases in `src/cli/mod.rs`:**
|
||||
|
||||
```rust
|
||||
("td", "todos"),
|
||||
("todo", "todos"),
|
||||
```
|
||||
|
||||
**Action type grouping:**
|
||||
|
||||
| Group | Actions |
|
||||
|-------|---------|
|
||||
| Assignments | `assigned` |
|
||||
| Mentions | `mentioned`, `directly_addressed` |
|
||||
| Approvals | `approval_required` |
|
||||
| Build Issues | `build_failed`, `unmergeable` |
|
||||
|
||||
**Robot mode schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"todos": [{
|
||||
"id": 123,
|
||||
"gitlab_todo_id": 456,
|
||||
"action": "mentioned",
|
||||
"target_type": "Issue",
|
||||
"target_iid": 42,
|
||||
"target_title": "Fix login bug",
|
||||
"target_url": "https://...",
|
||||
"project_path": "group/repo",
|
||||
"author_username": "jdoe",
|
||||
"body": "Hey @you, can you look at this?",
|
||||
"created_at_iso": "2026-02-20T10:00:00Z",
|
||||
"is_external": false
|
||||
}],
|
||||
"counts": {
|
||||
"total": 8,
|
||||
"assigned": 2,
|
||||
"mentioned": 5,
|
||||
"approval_required": 1,
|
||||
"build_failed": 0,
|
||||
"unmergeable": 0,
|
||||
"other": 0
|
||||
}
|
||||
},
|
||||
"meta": {"elapsed_ms": 42}
|
||||
}
|
||||
```
|
||||
|
||||
**Minimal fields:** `gitlab_todo_id`, `action`, `target_type`, `target_iid`, `project_path`, `is_external`
|
||||
|
||||
---
|
||||
|
||||
### IMP-6: `lore me` Integration
|
||||
|
||||
**Fulfills:** AC-18, AC-19, AC-20, AC-21, AC-22, AC-23
|
||||
|
||||
**Types to add/extend in `src/cli/commands/me/types.rs`:**
|
||||
|
||||
```rust
|
||||
// EXTEND
|
||||
pub struct MeSummary {
|
||||
// ... existing fields ...
|
||||
pub pending_todo_count: usize, // ADD
|
||||
}
|
||||
|
||||
// EXTEND
|
||||
pub enum ActivityEventType {
|
||||
// ... existing variants ...
|
||||
Todo, // ADD
|
||||
}
|
||||
|
||||
// EXTEND
|
||||
pub struct MeDashboard {
|
||||
// ... existing fields ...
|
||||
pub todos: Vec<MeTodo>, // ADD
|
||||
}
|
||||
|
||||
// NEW
|
||||
pub struct MeTodo {
|
||||
pub id: i64,
|
||||
pub gitlab_todo_id: i64,
|
||||
pub action: String,
|
||||
pub target_type: String,
|
||||
pub target_iid: Option<i64>,
|
||||
pub target_title: Option<String>,
|
||||
pub target_url: String,
|
||||
pub project_path: String,
|
||||
pub author_username: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub is_external: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**Warning for `--project` with `--todos` (AC-22):**
|
||||
|
||||
```rust
|
||||
if args.todos && args.project.is_some() {
|
||||
eprintln!("Warning: Todos are account-wide; project filter not applied");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IMP-7: Error Handling
|
||||
|
||||
**Fulfills:** AC-28, AC-29, AC-30
|
||||
|
||||
| Error | Behavior |
|
||||
|-------|----------|
|
||||
| 403 Forbidden | Log warning, skip todo sync, continue with other entities |
|
||||
| 429 Rate Limited | Respect `Retry-After` header using existing retry policy |
|
||||
| Malformed JSON | Log warning with todo ID, skip item, set `purge_allowed=false`, continue batch |
|
||||
|
||||
**Rationale for purge disable on malformed JSON:** If we can't decode a todo, we don't know its `gitlab_todo_id`. Without that, we might accidentally purge a valid todo that was simply malformed in transit. Disabling purge for that sync is the safe choice.
|
||||
|
||||
---
|
||||
|
||||
### IMP-8: Test Fixtures
|
||||
|
||||
**Fulfills:** AC-34
|
||||
|
||||
**Location:** `tests/fixtures/todos/`
|
||||
|
||||
**`todos_pending.json`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 102,
|
||||
"project": {"id": 2, "path_with_namespace": "diaspora/client"},
|
||||
"author": {"id": 1, "username": "admin"},
|
||||
"action_name": "mentioned",
|
||||
"target_type": "Issue",
|
||||
"target": {"id": 11, "iid": 4, "title": "Inventory system"},
|
||||
"target_url": "https://gitlab.example.com/diaspora/client/-/issues/4",
|
||||
"body": "@user please review",
|
||||
"state": "pending",
|
||||
"created_at": "2026-02-20T10:00:00.000Z",
|
||||
"updated_at": "2026-02-20T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**`todos_empty.json`:** `[]`
|
||||
|
||||
**`todos_commit_target.json`:** (target.id is string SHA)
|
||||
|
||||
**`todos_niche_actions.json`:** (merge_train_removed, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Rollout Slices
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```
|
||||
Slice A ──────► Slice B ──────┬──────► Slice C
|
||||
(Schema) (Sync) │ (`lore todos`)
|
||||
│
|
||||
└──────► Slice D
|
||||
(`lore me`)
|
||||
|
||||
Slice C ───┬───► Slice E
|
||||
Slice D ───┘ (Polish)
|
||||
```
|
||||
|
||||
### Slice A: Schema + Client
|
||||
|
||||
**ACs:** AC-1, AC-2, AC-3, AC-4, AC-5
|
||||
**IMPs:** IMP-1, IMP-2, IMP-4
|
||||
**Deliverable:** Migration + client method + deserialization tests pass
|
||||
|
||||
### Slice B: Sync Integration
|
||||
|
||||
**ACs:** AC-6, AC-7, AC-8, AC-9, AC-28, AC-29, AC-30
|
||||
**IMPs:** IMP-3, IMP-7
|
||||
**Deliverable:** `lore sync` fetches todos; `--no-todos` works
|
||||
|
||||
### Slice C: `lore todos` Command
|
||||
|
||||
**ACs:** AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-24, AC-25
|
||||
**IMPs:** IMP-5
|
||||
**Deliverable:** `lore todos` and `lore --robot todos` work
|
||||
|
||||
### Slice D: `lore me` Integration
|
||||
|
||||
**ACs:** AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-26, AC-27
|
||||
**IMPs:** IMP-6
|
||||
**Deliverable:** `lore me --todos` works; summary shows count
|
||||
|
||||
### Slice E: Polish
|
||||
|
||||
**ACs:** AC-31, AC-32, AC-33, AC-34
|
||||
**IMPs:** IMP-8
|
||||
**Deliverable:** Docs updated; all quality gates pass
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Write operations | Read-only | Complexity; glab handles writes |
|
||||
| Storage | SQLite | Consistent with existing architecture |
|
||||
| Project filter | Account-wide only | GitLab API is user-scoped |
|
||||
| Action type display | Core only | Reduce noise; store all for future |
|
||||
| Attention state | Separate signal | Todos are notifications, not engagement |
|
||||
| History | Pending only | Simplicity; done todos have no value locally |
|
||||
| Grouping | By action type | Matches GitLab UI; aids triage |
|
||||
| Purge strategy | Two-generation grace | Protects against pagination drift during sync |
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Write operations (mark as done)
|
||||
- Done todo history tracking
|
||||
- Filters beyond `--limit`
|
||||
- Todo-based attention state boosting
|
||||
- Notification settings API
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [GitLab To-Do List API](https://docs.gitlab.com/api/todos/)
|
||||
- [GitLab User Todos](https://docs.gitlab.com/user/todos/)
|
||||
@@ -4,7 +4,7 @@ title: ""
|
||||
status: iterating
|
||||
iteration: 6
|
||||
target_iterations: 8
|
||||
beads_revision: 1
|
||||
beads_revision: 2
|
||||
related_plans: []
|
||||
created: 2026-02-08
|
||||
updated: 2026-02-12
|
||||
|
||||
@@ -21,6 +21,11 @@ pub enum CorrectionRule {
|
||||
SingleDashLongFlag,
|
||||
CaseNormalization,
|
||||
FuzzyFlag,
|
||||
SubcommandAlias,
|
||||
ValueNormalization,
|
||||
ValueFuzzy,
|
||||
FlagPrefix,
|
||||
NoColorExpansion,
|
||||
}
|
||||
|
||||
/// Result of the correction pass over raw args.
|
||||
@@ -40,6 +45,7 @@ const GLOBAL_FLAGS: &[&str] = &[
|
||||
"--robot",
|
||||
"--json",
|
||||
"--color",
|
||||
"--icons",
|
||||
"--quiet",
|
||||
"--no-quiet",
|
||||
"--verbose",
|
||||
@@ -119,8 +125,15 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--no-docs",
|
||||
"--no-events",
|
||||
"--no-file-changes",
|
||||
"--no-status",
|
||||
"--dry-run",
|
||||
"--no-dry-run",
|
||||
"--timings",
|
||||
"--lock",
|
||||
"--issue",
|
||||
"--mr",
|
||||
"--project",
|
||||
"--preflight-only",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -162,7 +175,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--project",
|
||||
"--since",
|
||||
"--depth",
|
||||
"--expand-mentions",
|
||||
"--no-mentions",
|
||||
"--limit",
|
||||
"--fields",
|
||||
"--max-seeds",
|
||||
@@ -170,6 +183,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--max-evidence",
|
||||
],
|
||||
),
|
||||
("related", &["--limit", "--project"]),
|
||||
(
|
||||
"who",
|
||||
&[
|
||||
@@ -183,9 +197,38 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--fields",
|
||||
"--detail",
|
||||
"--no-detail",
|
||||
"--as-of",
|
||||
"--explain-score",
|
||||
"--include-bots",
|
||||
"--include-closed",
|
||||
"--all-history",
|
||||
],
|
||||
),
|
||||
("drift", &["--threshold", "--project"]),
|
||||
(
|
||||
"notes",
|
||||
&[
|
||||
"--limit",
|
||||
"--fields",
|
||||
"--author",
|
||||
"--note-type",
|
||||
"--contains",
|
||||
"--note-id",
|
||||
"--gitlab-note-id",
|
||||
"--discussion-id",
|
||||
"--include-system",
|
||||
"--for-issue",
|
||||
"--for-mr",
|
||||
"--project",
|
||||
"--since",
|
||||
"--until",
|
||||
"--path",
|
||||
"--resolution",
|
||||
"--sort",
|
||||
"--asc",
|
||||
"--open",
|
||||
],
|
||||
),
|
||||
(
|
||||
"init",
|
||||
&[
|
||||
@@ -197,6 +240,25 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--default-project",
|
||||
],
|
||||
),
|
||||
(
|
||||
"file-history",
|
||||
&[
|
||||
"--project",
|
||||
"--discussions",
|
||||
"--no-follow-renames",
|
||||
"--merged",
|
||||
"--limit",
|
||||
],
|
||||
),
|
||||
(
|
||||
"trace",
|
||||
&[
|
||||
"--project",
|
||||
"--discussions",
|
||||
"--no-follow-renames",
|
||||
"--limit",
|
||||
],
|
||||
),
|
||||
("generate-docs", &["--full", "--project"]),
|
||||
("completions", &[]),
|
||||
("robot-docs", &["--brief"]),
|
||||
@@ -225,6 +287,20 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
),
|
||||
("show", &["--project"]),
|
||||
("reset", &["--yes"]),
|
||||
(
|
||||
"me",
|
||||
&[
|
||||
"--issues",
|
||||
"--mrs",
|
||||
"--activity",
|
||||
"--since",
|
||||
"--project",
|
||||
"--all",
|
||||
"--user",
|
||||
"--fields",
|
||||
"--reset-cursor",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Valid values for enum-like flags, used for post-clap error enhancement.
|
||||
@@ -232,18 +308,47 @@ pub const ENUM_VALUES: &[(&str, &[&str])] = &[
|
||||
("--state", &["opened", "closed", "merged", "locked", "all"]),
|
||||
("--mode", &["lexical", "hybrid", "semantic"]),
|
||||
("--sort", &["updated", "created", "iid"]),
|
||||
("--type", &["issue", "mr", "discussion"]),
|
||||
("--type", &["issue", "mr", "discussion", "note"]),
|
||||
("--fts-mode", &["safe", "raw"]),
|
||||
("--color", &["auto", "always", "never"]),
|
||||
("--log-format", &["text", "json"]),
|
||||
("--for", &["issue", "mr"]),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcommand alias map (for forms clap aliases can't express)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subcommand aliases for non-standard forms (underscores, no separators).
|
||||
/// Clap `visible_alias`/`alias` handles hyphenated forms (`merge-requests`);
|
||||
/// this map catches the rest.
|
||||
const SUBCOMMAND_ALIASES: &[(&str, &str)] = &[
|
||||
("merge_requests", "mrs"),
|
||||
("merge_request", "mrs"),
|
||||
("mergerequests", "mrs"),
|
||||
("mergerequest", "mrs"),
|
||||
("generate_docs", "generate-docs"),
|
||||
("generatedocs", "generate-docs"),
|
||||
("gendocs", "generate-docs"),
|
||||
("gen-docs", "generate-docs"),
|
||||
("robot_docs", "robot-docs"),
|
||||
("robotdocs", "robot-docs"),
|
||||
("sync_status", "status"),
|
||||
("syncstatus", "status"),
|
||||
("auth_test", "auth"),
|
||||
("authtest", "auth"),
|
||||
("file_history", "file-history"),
|
||||
("filehistory", "file-history"),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Correction thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FUZZY_FLAG_THRESHOLD: f64 = 0.8;
|
||||
/// Stricter threshold for robot mode — only high-confidence corrections to
|
||||
/// avoid misleading agents. Still catches obvious typos like `--projct`.
|
||||
const FUZZY_FLAG_THRESHOLD_STRICT: f64 = 0.9;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logic
|
||||
@@ -303,20 +408,29 @@ fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> {
|
||||
|
||||
/// Run the pre-clap correction pass on raw args.
|
||||
///
|
||||
/// When `strict` is true (robot mode), only deterministic corrections are applied
|
||||
/// (single-dash long flags, case normalization). Fuzzy matching is disabled to
|
||||
/// prevent misleading agents with speculative corrections.
|
||||
/// Three-phase pipeline:
|
||||
/// - Phase A: Subcommand alias correction (case-insensitive alias map)
|
||||
/// - Phase B: Per-arg flag corrections (single-dash, case, prefix, fuzzy)
|
||||
/// - Phase C: Enum value normalization (case + fuzzy + prefix on known values)
|
||||
///
|
||||
/// When `strict` is true (robot mode), fuzzy matching uses a higher threshold
|
||||
/// (0.9 vs 0.8) to avoid speculative corrections while still catching obvious
|
||||
/// typos like `--projct` → `--project`.
|
||||
///
|
||||
/// Returns the (possibly modified) args and any corrections applied.
|
||||
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
||||
let subcommand = detect_subcommand(&raw);
|
||||
let valid = valid_flags_for(subcommand);
|
||||
|
||||
let mut corrected = Vec::with_capacity(raw.len());
|
||||
let mut corrections = Vec::new();
|
||||
|
||||
// Phase A: Subcommand alias correction
|
||||
let args = correct_subcommand(raw, &mut corrections);
|
||||
|
||||
// Phase B: Per-arg flag corrections
|
||||
let valid = valid_flags_for(detect_subcommand(&args));
|
||||
|
||||
let mut corrected = Vec::with_capacity(args.len());
|
||||
let mut past_terminator = false;
|
||||
|
||||
for arg in raw {
|
||||
for arg in args {
|
||||
// B1: Stop correcting after POSIX `--` option terminator
|
||||
if arg == "--" {
|
||||
past_terminator = true;
|
||||
@@ -330,20 +444,197 @@ pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
||||
}
|
||||
|
||||
if let Some(fixed) = try_correct(&arg, &valid, strict) {
|
||||
let s = fixed.corrected.clone();
|
||||
corrections.push(fixed);
|
||||
corrected.push(s);
|
||||
if fixed.rule == CorrectionRule::NoColorExpansion {
|
||||
// Expand --no-color → --color never
|
||||
corrections.push(Correction {
|
||||
original: fixed.original,
|
||||
corrected: "--color never".to_string(),
|
||||
rule: CorrectionRule::NoColorExpansion,
|
||||
confidence: 1.0,
|
||||
});
|
||||
corrected.push("--color".to_string());
|
||||
corrected.push("never".to_string());
|
||||
} else {
|
||||
let s = fixed.corrected.clone();
|
||||
corrections.push(fixed);
|
||||
corrected.push(s);
|
||||
}
|
||||
} else {
|
||||
corrected.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase C: Enum value normalization
|
||||
normalize_enum_values(&mut corrected, &mut corrections);
|
||||
|
||||
CorrectionResult {
|
||||
args: corrected,
|
||||
corrections,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase A: Replace subcommand aliases with their canonical names.
|
||||
///
|
||||
/// Handles forms that can't be expressed as clap `alias`/`visible_alias`
|
||||
/// (underscores, no-separator forms). Case-insensitive matching.
|
||||
fn correct_subcommand(mut args: Vec<String>, corrections: &mut Vec<Correction>) -> Vec<String> {
|
||||
// Find the subcommand position index, then check the alias map.
|
||||
// Can't use iterators easily because we need to mutate args[i].
|
||||
let mut skip_next = false;
|
||||
let mut subcmd_idx = None;
|
||||
for (i, arg) in args.iter().enumerate().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with('-') {
|
||||
if arg.contains('=') {
|
||||
continue;
|
||||
}
|
||||
if matches!(arg.as_str(), "--config" | "-c" | "--color" | "--log-format") {
|
||||
skip_next = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
subcmd_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
if let Some(i) = subcmd_idx
|
||||
&& let Some((_, canonical)) = SUBCOMMAND_ALIASES
|
||||
.iter()
|
||||
.find(|(alias, _)| alias.eq_ignore_ascii_case(&args[i]))
|
||||
{
|
||||
corrections.push(Correction {
|
||||
original: args[i].clone(),
|
||||
corrected: (*canonical).to_string(),
|
||||
rule: CorrectionRule::SubcommandAlias,
|
||||
confidence: 1.0,
|
||||
});
|
||||
args[i] = (*canonical).to_string();
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// Phase C: Normalize enum values for flags with known valid values.
|
||||
///
|
||||
/// Handles both `--flag value` and `--flag=value` forms. Corrections are:
|
||||
/// 1. Case normalization: `Opened` → `opened`
|
||||
/// 2. Prefix expansion: `open` → `opened` (only if unambiguous)
|
||||
/// 3. Fuzzy matching: `opend` → `opened`
|
||||
fn normalize_enum_values(args: &mut [String], corrections: &mut Vec<Correction>) {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
// Respect POSIX `--` option terminator — don't normalize values after it
|
||||
if args[i] == "--" {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle --flag=value form
|
||||
if let Some(eq_pos) = args[i].find('=') {
|
||||
let flag = args[i][..eq_pos].to_string();
|
||||
let value = args[i][eq_pos + 1..].to_string();
|
||||
if let Some(valid_vals) = lookup_enum_values(&flag)
|
||||
&& let Some((corrected_val, is_case_only)) = normalize_value(&value, valid_vals)
|
||||
{
|
||||
let original = args[i].clone();
|
||||
let corrected = format!("{flag}={corrected_val}");
|
||||
args[i] = corrected.clone();
|
||||
corrections.push(Correction {
|
||||
original,
|
||||
corrected,
|
||||
rule: if is_case_only {
|
||||
CorrectionRule::ValueNormalization
|
||||
} else {
|
||||
CorrectionRule::ValueFuzzy
|
||||
},
|
||||
confidence: 0.95,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle --flag value form
|
||||
if args[i].starts_with("--")
|
||||
&& let Some(valid_vals) = lookup_enum_values(&args[i])
|
||||
&& i + 1 < args.len()
|
||||
&& !args[i + 1].starts_with('-')
|
||||
{
|
||||
let value = args[i + 1].clone();
|
||||
if let Some((corrected_val, is_case_only)) = normalize_value(&value, valid_vals) {
|
||||
let original = args[i + 1].clone();
|
||||
args[i + 1] = corrected_val.to_string();
|
||||
corrections.push(Correction {
|
||||
original,
|
||||
corrected: corrected_val.to_string(),
|
||||
rule: if is_case_only {
|
||||
CorrectionRule::ValueNormalization
|
||||
} else {
|
||||
CorrectionRule::ValueFuzzy
|
||||
},
|
||||
confidence: 0.95,
|
||||
});
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up valid enum values for a flag (case-insensitive flag name match).
|
||||
fn lookup_enum_values(flag: &str) -> Option<&'static [&'static str]> {
|
||||
let lower = flag.to_lowercase();
|
||||
ENUM_VALUES
|
||||
.iter()
|
||||
.find(|(f, _)| f.to_lowercase() == lower)
|
||||
.map(|(_, vals)| *vals)
|
||||
}
|
||||
|
||||
/// Try to normalize a value against a set of valid values.
|
||||
///
|
||||
/// Returns `Some((corrected, is_case_only))` if a correction is needed:
|
||||
/// - `is_case_only = true` for pure case normalization
|
||||
/// - `is_case_only = false` for prefix/fuzzy corrections
|
||||
///
|
||||
/// Returns `None` if the value is already valid or no match is found.
|
||||
fn normalize_value(input: &str, valid_values: &[&str]) -> Option<(String, bool)> {
|
||||
// Already valid (exact match)? No correction needed.
|
||||
if valid_values.contains(&input) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lower = input.to_lowercase();
|
||||
|
||||
// Case-insensitive exact match
|
||||
if let Some(&val) = valid_values.iter().find(|v| v.to_lowercase() == lower) {
|
||||
return Some((val.to_string(), true));
|
||||
}
|
||||
|
||||
// Prefix match (e.g., "open" → "opened") — only if unambiguous
|
||||
let prefix_matches: Vec<&&str> = valid_values
|
||||
.iter()
|
||||
.filter(|v| v.starts_with(&*lower))
|
||||
.collect();
|
||||
if prefix_matches.len() == 1 {
|
||||
return Some(((*prefix_matches[0]).to_string(), false));
|
||||
}
|
||||
|
||||
// Fuzzy match
|
||||
let best = valid_values
|
||||
.iter()
|
||||
.map(|v| (*v, jaro_winkler(&lower, v)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
if let Some((val, score)) = best
|
||||
&& score >= 0.8
|
||||
{
|
||||
return Some((val.to_string(), false));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Clap built-in flags that should never be corrected. These are handled by clap
|
||||
/// directly and are not in our GLOBAL_FLAGS registry.
|
||||
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
||||
@@ -352,12 +643,27 @@ const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
||||
///
|
||||
/// When `strict` is true, fuzzy matching is disabled — only deterministic
|
||||
/// corrections (single-dash fix, case normalization) are applied.
|
||||
///
|
||||
/// Special case: `--no-color` is rewritten to `--color never` by returning
|
||||
/// the `--color` correction and letting the caller handle arg insertion.
|
||||
/// However, since we correct one arg at a time, we use `NoColorExpansion`
|
||||
/// to signal that the next phase should insert `never` after this arg.
|
||||
fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correction> {
|
||||
// Only attempt correction on flag-like args (starts with `-`)
|
||||
if !arg.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Special case: --no-color → --color never (common agent/user expectation)
|
||||
if arg.eq_ignore_ascii_case("--no-color") {
|
||||
return Some(Correction {
|
||||
original: arg.to_string(),
|
||||
corrected: "--no-color".to_string(), // sentinel; expanded in correct_args
|
||||
rule: CorrectionRule::NoColorExpansion,
|
||||
confidence: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
// B2: Never correct clap built-in flags (--help, --version)
|
||||
let flag_part_for_builtin = if let Some(eq_pos) = arg.find('=') {
|
||||
&arg[..eq_pos]
|
||||
@@ -462,10 +768,34 @@ fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correcti
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 3: Fuzzy flag match — `--staate` -> `--state` (skip in strict mode)
|
||||
if !strict
|
||||
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||
&& score >= FUZZY_FLAG_THRESHOLD
|
||||
// Rule 3: Prefix match — `--proj` -> `--project` (only if unambiguous)
|
||||
let prefix_matches: Vec<&str> = valid_flags
|
||||
.iter()
|
||||
.filter(|f| f.starts_with(&*lower) && f.to_lowercase() != lower)
|
||||
.copied()
|
||||
.collect();
|
||||
if prefix_matches.len() == 1 {
|
||||
let matched = prefix_matches[0];
|
||||
let corrected = match value_suffix {
|
||||
Some(suffix) => format!("{matched}{suffix}"),
|
||||
None => matched.to_string(),
|
||||
};
|
||||
return Some(Correction {
|
||||
original: arg.to_string(),
|
||||
corrected,
|
||||
rule: CorrectionRule::FlagPrefix,
|
||||
confidence: 0.95,
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 4: Fuzzy flag match — higher threshold in strict/robot mode
|
||||
let threshold = if strict {
|
||||
FUZZY_FLAG_THRESHOLD_STRICT
|
||||
} else {
|
||||
FUZZY_FLAG_THRESHOLD
|
||||
};
|
||||
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||
&& score >= threshold
|
||||
{
|
||||
let corrected = match value_suffix {
|
||||
Some(suffix) => format!("{best_flag}{suffix}"),
|
||||
@@ -483,9 +813,21 @@ fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correcti
|
||||
}
|
||||
|
||||
/// Find the best fuzzy match among valid flags for a given (lowercased) input.
|
||||
///
|
||||
/// Applies a length guard to prevent short candidates (e.g. `--for`, 5 chars
|
||||
/// including dashes) from inflating Jaro-Winkler scores against long inputs.
|
||||
/// When the input is more than 40% longer than a candidate, that candidate is
|
||||
/// excluded from fuzzy consideration (it can still match via prefix rule).
|
||||
fn best_fuzzy_match<'a>(input: &str, valid_flags: &[&'a str]) -> Option<(&'a str, f64)> {
|
||||
valid_flags
|
||||
.iter()
|
||||
.filter(|&&flag| {
|
||||
// Guard: skip short candidates when input is much longer.
|
||||
// e.g. "--foobar" (8 chars) should not fuzzy-match "--for" (5 chars)
|
||||
// Ratio: input must be within 1.4x the candidate length.
|
||||
let max_input_len = (flag.len() as f64 * 1.4) as usize;
|
||||
input.len() <= max_input_len
|
||||
})
|
||||
.map(|&flag| (flag, jaro_winkler(input, flag)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
}
|
||||
@@ -539,6 +881,33 @@ pub fn format_teaching_note(correction: &Correction) -> String {
|
||||
correction.corrected, correction.original
|
||||
)
|
||||
}
|
||||
CorrectionRule::SubcommandAlias => {
|
||||
format!(
|
||||
"Use canonical command name: {} (not {})",
|
||||
correction.corrected, correction.original
|
||||
)
|
||||
}
|
||||
CorrectionRule::ValueNormalization => {
|
||||
format!(
|
||||
"Values are lowercase: {} (not {})",
|
||||
correction.corrected, correction.original
|
||||
)
|
||||
}
|
||||
CorrectionRule::ValueFuzzy => {
|
||||
format!(
|
||||
"Correct value spelling: {} (not {})",
|
||||
correction.corrected, correction.original
|
||||
)
|
||||
}
|
||||
CorrectionRule::FlagPrefix => {
|
||||
format!(
|
||||
"Use full flag name: {} (not {})",
|
||||
correction.corrected, correction.original
|
||||
)
|
||||
}
|
||||
CorrectionRule::NoColorExpansion => {
|
||||
"Use `--color never` instead of `--no-color`".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,17 +1091,20 @@ mod tests {
|
||||
assert_eq!(result.args[1], "--help");
|
||||
}
|
||||
|
||||
// ---- I6: Strict mode (robot) disables fuzzy matching ----
|
||||
// ---- Strict mode (robot) uses higher fuzzy threshold ----
|
||||
|
||||
#[test]
|
||||
fn strict_mode_disables_fuzzy() {
|
||||
// Fuzzy match works in non-strict
|
||||
fn strict_mode_rejects_low_confidence_fuzzy() {
|
||||
// `--staate` vs `--state` — close but may be below strict threshold (0.9)
|
||||
// The exact score depends on Jaro-Winkler; this tests that the strict
|
||||
// threshold is higher than non-strict.
|
||||
let non_strict = correct_args(args("lore --robot issues --staate opened"), false);
|
||||
assert_eq!(non_strict.corrections.len(), 1);
|
||||
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||
|
||||
// Fuzzy match disabled in strict
|
||||
let strict = correct_args(args("lore --robot issues --staate opened"), true);
|
||||
// In strict mode, same typo might or might not match depending on JW score.
|
||||
// We verify that at least wildly wrong flags are still rejected.
|
||||
let strict = correct_args(args("lore --robot issues --xyzzy foo"), true);
|
||||
assert!(strict.corrections.is_empty());
|
||||
}
|
||||
|
||||
@@ -751,6 +1123,155 @@ mod tests {
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
}
|
||||
|
||||
// ---- Subcommand alias correction ----
|
||||
|
||||
#[test]
|
||||
fn subcommand_alias_merge_requests_underscore() {
|
||||
let result = correct_args(args("lore --robot merge_requests -n 10"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.rule == CorrectionRule::SubcommandAlias && c.corrected == "mrs")
|
||||
);
|
||||
assert!(result.args.contains(&"mrs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_alias_mergerequests_no_sep() {
|
||||
let result = correct_args(args("lore --robot mergerequests"), false);
|
||||
assert!(result.corrections.iter().any(|c| c.corrected == "mrs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_alias_generate_docs_underscore() {
|
||||
let result = correct_args(args("lore generate_docs"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.corrected == "generate-docs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_alias_case_insensitive() {
|
||||
let result = correct_args(args("lore Merge_Requests"), false);
|
||||
assert!(result.corrections.iter().any(|c| c.corrected == "mrs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_alias_valid_command_untouched() {
|
||||
let result = correct_args(args("lore issues -n 10"), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
}
|
||||
|
||||
// ---- Enum value normalization ----
|
||||
|
||||
#[test]
|
||||
fn value_case_normalization() {
|
||||
let result = correct_args(args("lore issues --state Opened"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.rule == CorrectionRule::ValueNormalization && c.corrected == "opened")
|
||||
);
|
||||
assert!(result.args.contains(&"opened".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_case_normalization_eq_form() {
|
||||
let result = correct_args(args("lore issues --state=Opened"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.corrected == "--state=opened")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_prefix_expansion() {
|
||||
// "open" is a unique prefix of "opened"
|
||||
let result = correct_args(args("lore issues --state open"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.corrected == "opened" && c.rule == CorrectionRule::ValueFuzzy)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_fuzzy_typo() {
|
||||
let result = correct_args(args("lore issues --state opend"), false);
|
||||
assert!(result.corrections.iter().any(|c| c.corrected == "opened"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_already_valid_untouched() {
|
||||
let result = correct_args(args("lore issues --state opened"), false);
|
||||
// No value corrections expected (flag corrections may still exist)
|
||||
assert!(!result.corrections.iter().any(|c| matches!(
|
||||
c.rule,
|
||||
CorrectionRule::ValueNormalization | CorrectionRule::ValueFuzzy
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_mode_case() {
|
||||
let result = correct_args(args("lore search --mode Hybrid query"), false);
|
||||
assert!(result.corrections.iter().any(|c| c.corrected == "hybrid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_normalization_respects_option_terminator() {
|
||||
// Values after `--` are positional and must not be corrected
|
||||
let result = correct_args(args("lore search -- --state Opened"), false);
|
||||
assert!(!result.corrections.iter().any(|c| matches!(
|
||||
c.rule,
|
||||
CorrectionRule::ValueNormalization | CorrectionRule::ValueFuzzy
|
||||
)));
|
||||
assert_eq!(result.args[4], "Opened"); // preserved as-is
|
||||
}
|
||||
|
||||
// ---- Flag prefix matching ----
|
||||
|
||||
#[test]
|
||||
fn flag_prefix_project() {
|
||||
let result = correct_args(args("lore issues --proj group/repo"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.rule == CorrectionRule::FlagPrefix && c.corrected == "--project")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_prefix_ambiguous_not_corrected() {
|
||||
// --s could be --state, --since, --sort, --status — ambiguous
|
||||
let result = correct_args(args("lore issues --s opened"), false);
|
||||
assert!(
|
||||
!result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.rule == CorrectionRule::FlagPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_prefix_with_eq_value() {
|
||||
let result = correct_args(args("lore issues --proj=group/repo"), false);
|
||||
assert!(
|
||||
result
|
||||
.corrections
|
||||
.iter()
|
||||
.any(|c| c.corrected == "--project=group/repo")
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Teaching notes ----
|
||||
|
||||
#[test]
|
||||
@@ -790,6 +1311,90 @@ mod tests {
|
||||
assert!(note.contains("spelling"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teaching_note_subcommand_alias() {
|
||||
let c = Correction {
|
||||
original: "merge_requests".to_string(),
|
||||
corrected: "mrs".to_string(),
|
||||
rule: CorrectionRule::SubcommandAlias,
|
||||
confidence: 1.0,
|
||||
};
|
||||
let note = format_teaching_note(&c);
|
||||
assert!(note.contains("canonical"));
|
||||
assert!(note.contains("mrs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teaching_note_value_normalization() {
|
||||
let c = Correction {
|
||||
original: "Opened".to_string(),
|
||||
corrected: "opened".to_string(),
|
||||
rule: CorrectionRule::ValueNormalization,
|
||||
confidence: 0.95,
|
||||
};
|
||||
let note = format_teaching_note(&c);
|
||||
assert!(note.contains("lowercase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teaching_note_flag_prefix() {
|
||||
let c = Correction {
|
||||
original: "--proj".to_string(),
|
||||
corrected: "--project".to_string(),
|
||||
rule: CorrectionRule::FlagPrefix,
|
||||
confidence: 0.95,
|
||||
};
|
||||
let note = format_teaching_note(&c);
|
||||
assert!(note.contains("full flag name"));
|
||||
}
|
||||
|
||||
// ---- --no-color expansion ----
|
||||
|
||||
#[test]
|
||||
fn no_color_expands_to_color_never() {
|
||||
let result = correct_args(args("lore --no-color health"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].rule, CorrectionRule::NoColorExpansion);
|
||||
assert_eq!(result.args, args("lore --color never health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_color_case_insensitive() {
|
||||
let result = correct_args(args("lore --No-Color issues"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.args, args("lore --color never issues"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_color_with_robot_mode() {
|
||||
let result = correct_args(args("lore --robot --no-color health"), true);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.args, args("lore --robot --color never health"));
|
||||
}
|
||||
|
||||
// ---- Fuzzy matching length guard ----
|
||||
|
||||
#[test]
|
||||
fn foobar_does_not_match_for() {
|
||||
// --foobar (8 chars) should NOT fuzzy-match --for (5 chars)
|
||||
let result = correct_args(args("lore count --foobar issues"), false);
|
||||
assert!(
|
||||
!result.corrections.iter().any(|c| c.corrected == "--for"),
|
||||
"expected --foobar not to match --for"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fro_still_matches_for() {
|
||||
// --fro (5 chars) is short enough to fuzzy-match --for (5 chars)
|
||||
// and also qualifies as a prefix match
|
||||
let result = correct_args(args("lore count --fro issues"), false);
|
||||
assert!(
|
||||
result.corrections.iter().any(|c| c.corrected == "--for"),
|
||||
"expected --fro to match --for"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Post-clap suggestion helpers ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::core::config::Config;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::error::Result;
|
||||
use crate::gitlab::GitLabClient;
|
||||
|
||||
pub struct AuthTestResult {
|
||||
@@ -11,17 +11,7 @@ pub struct AuthTestResult {
|
||||
pub async fn run_auth_test(config_path: Option<&str>) -> Result<AuthTestResult> {
|
||||
let config = Config::load(config_path)?;
|
||||
|
||||
let token = std::env::var(&config.gitlab.token_env_var)
|
||||
.map(|t| t.trim().to_string())
|
||||
.map_err(|_| LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
})?;
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
});
|
||||
}
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
|
||||
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -178,27 +178,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
|
||||
})
|
||||
}
|
||||
|
||||
fn format_number(n: i64) -> String {
|
||||
let (prefix, abs) = if n < 0 {
|
||||
("-", n.unsigned_abs())
|
||||
} else {
|
||||
("", n.unsigned_abs())
|
||||
};
|
||||
|
||||
let s = abs.to_string();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut result = String::from(prefix);
|
||||
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i > 0 && (chars.len() - i).is_multiple_of(3) {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(*c);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CountJsonOutput {
|
||||
ok: bool,
|
||||
@@ -278,16 +257,19 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_event_count(counts: &EventCounts) {
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
style("Event Type").cyan().bold(),
|
||||
style("Issues").bold(),
|
||||
style("MRs").bold(),
|
||||
style("Total").bold()
|
||||
Theme::info().bold().render("Event Type"),
|
||||
Theme::bold().render("Issues"),
|
||||
Theme::bold().render("MRs"),
|
||||
Theme::bold().render("Total")
|
||||
);
|
||||
|
||||
let state_total = counts.state_issue + counts.state_mr;
|
||||
@@ -297,33 +279,33 @@ pub fn print_event_count(counts: &EventCounts) {
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
"State events",
|
||||
format_number(counts.state_issue as i64),
|
||||
format_number(counts.state_mr as i64),
|
||||
format_number(state_total as i64)
|
||||
render::format_number(counts.state_issue as i64),
|
||||
render::format_number(counts.state_mr as i64),
|
||||
render::format_number(state_total as i64)
|
||||
);
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
"Label events",
|
||||
format_number(counts.label_issue as i64),
|
||||
format_number(counts.label_mr as i64),
|
||||
format_number(label_total as i64)
|
||||
render::format_number(counts.label_issue as i64),
|
||||
render::format_number(counts.label_mr as i64),
|
||||
render::format_number(label_total as i64)
|
||||
);
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
"Milestone events",
|
||||
format_number(counts.milestone_issue as i64),
|
||||
format_number(counts.milestone_mr as i64),
|
||||
format_number(milestone_total as i64)
|
||||
render::format_number(counts.milestone_issue as i64),
|
||||
render::format_number(counts.milestone_mr as i64),
|
||||
render::format_number(milestone_total as i64)
|
||||
);
|
||||
|
||||
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
|
||||
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
style("Total").bold(),
|
||||
format_number(total_issues as i64),
|
||||
format_number(total_mrs as i64),
|
||||
style(format_number(counts.total() as i64)).bold()
|
||||
Theme::bold().render("Total"),
|
||||
render::format_number(total_issues as i64),
|
||||
render::format_number(total_mrs as i64),
|
||||
Theme::bold().render(&render::format_number(counts.total() as i64))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -346,61 +328,63 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_count(result: &CountResult) {
|
||||
let count_str = format_number(result.count);
|
||||
let count_str = render::format_number(result.count);
|
||||
|
||||
if let Some(system_count) = result.system_count {
|
||||
println!(
|
||||
"{}: {} {}",
|
||||
style(&result.entity).cyan(),
|
||||
style(&count_str).bold(),
|
||||
style(format!(
|
||||
"{}: {:>10} {}",
|
||||
Theme::info().render(&result.entity),
|
||||
Theme::bold().render(&count_str),
|
||||
Theme::dim().render(&format!(
|
||||
"(excluding {} system)",
|
||||
format_number(system_count)
|
||||
render::format_number(system_count)
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {}",
|
||||
style(&result.entity).cyan(),
|
||||
style(&count_str).bold()
|
||||
"{}: {:>10}",
|
||||
Theme::info().render(&result.entity),
|
||||
Theme::bold().render(&count_str)
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(breakdown) = &result.state_breakdown {
|
||||
println!(" opened: {}", format_number(breakdown.opened));
|
||||
println!(" opened: {:>10}", render::format_number(breakdown.opened));
|
||||
if let Some(merged) = breakdown.merged {
|
||||
println!(" merged: {}", format_number(merged));
|
||||
println!(" merged: {:>10}", render::format_number(merged));
|
||||
}
|
||||
println!(" closed: {}", format_number(breakdown.closed));
|
||||
println!(" closed: {:>10}", render::format_number(breakdown.closed));
|
||||
if let Some(locked) = breakdown.locked
|
||||
&& locked > 0
|
||||
{
|
||||
println!(" locked: {}", format_number(locked));
|
||||
println!(" locked: {:>10}", render::format_number(locked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::render;
|
||||
|
||||
#[test]
|
||||
fn format_number_handles_small_numbers() {
|
||||
assert_eq!(format_number(0), "0");
|
||||
assert_eq!(format_number(1), "1");
|
||||
assert_eq!(format_number(100), "100");
|
||||
assert_eq!(format_number(999), "999");
|
||||
assert_eq!(render::format_number(0), "0");
|
||||
assert_eq!(render::format_number(1), "1");
|
||||
assert_eq!(render::format_number(100), "100");
|
||||
assert_eq!(render::format_number(999), "999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_adds_thousands_separators() {
|
||||
assert_eq!(format_number(1000), "1,000");
|
||||
assert_eq!(format_number(12345), "12,345");
|
||||
assert_eq!(format_number(1234567), "1,234,567");
|
||||
assert_eq!(render::format_number(1000), "1,000");
|
||||
assert_eq!(render::format_number(12345), "12,345");
|
||||
assert_eq!(render::format_number(1234567), "1,234,567");
|
||||
}
|
||||
}
|
||||
|
||||
292
src/cli/commands/cron.rs
Normal file
292
src/cli/commands/cron.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::render::Theme;
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::cron::{
|
||||
CronInstallResult, CronStatusResult, CronUninstallResult, cron_status, install_cron,
|
||||
uninstall_cron,
|
||||
};
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::Result;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
// ── install ──
|
||||
|
||||
pub fn run_cron_install(interval_minutes: u32) -> Result<CronInstallResult> {
|
||||
install_cron(interval_minutes)
|
||||
}
|
||||
|
||||
pub fn print_cron_install(result: &CronInstallResult) {
|
||||
if result.replaced {
|
||||
println!(
|
||||
" {} cron entry updated (was already installed)",
|
||||
Theme::success().render("Updated")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} cron entry installed",
|
||||
Theme::success().render("Installed")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(" {} {}", Theme::dim().render("entry:"), result.entry);
|
||||
println!(
|
||||
" {} every {} minutes",
|
||||
Theme::dim().render("interval:"),
|
||||
result.interval_minutes
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
Theme::dim().render("log:"),
|
||||
result.log_path.display()
|
||||
);
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
println!();
|
||||
println!(
|
||||
" {} On macOS, the terminal running cron may need",
|
||||
Theme::warning().render("Note:")
|
||||
);
|
||||
println!(" Full Disk Access in System Settings > Privacy & Security.");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronInstallJson {
|
||||
ok: bool,
|
||||
data: CronInstallData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronInstallData {
|
||||
action: &'static str,
|
||||
entry: String,
|
||||
interval_minutes: u32,
|
||||
log_path: String,
|
||||
replaced: bool,
|
||||
}
|
||||
|
||||
pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) {
|
||||
let output = CronInstallJson {
|
||||
ok: true,
|
||||
data: CronInstallData {
|
||||
action: "install",
|
||||
entry: result.entry.clone(),
|
||||
interval_minutes: result.interval_minutes,
|
||||
log_path: result.log_path.display().to_string(),
|
||||
replaced: result.replaced,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── uninstall ──
|
||||
|
||||
pub fn run_cron_uninstall() -> Result<CronUninstallResult> {
|
||||
uninstall_cron()
|
||||
}
|
||||
|
||||
pub fn print_cron_uninstall(result: &CronUninstallResult) {
|
||||
if result.was_installed {
|
||||
println!(
|
||||
" {} cron entry removed",
|
||||
Theme::success().render("Removed")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} no lore-sync cron entry found",
|
||||
Theme::dim().render("Nothing to remove:")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronUninstallJson {
|
||||
ok: bool,
|
||||
data: CronUninstallData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronUninstallData {
|
||||
action: &'static str,
|
||||
was_installed: bool,
|
||||
}
|
||||
|
||||
pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64) {
|
||||
let output = CronUninstallJson {
|
||||
ok: true,
|
||||
data: CronUninstallData {
|
||||
action: "uninstall",
|
||||
was_installed: result.was_installed,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── status ──
|
||||
|
||||
pub fn run_cron_status(config: &Config) -> Result<CronStatusInfo> {
|
||||
let status = cron_status()?;
|
||||
|
||||
// Query last sync run from DB
|
||||
let last_sync = get_last_sync_time(config).unwrap_or_default();
|
||||
|
||||
Ok(CronStatusInfo { status, last_sync })
|
||||
}
|
||||
|
||||
pub struct CronStatusInfo {
|
||||
pub status: CronStatusResult,
|
||||
pub last_sync: Option<LastSyncInfo>,
|
||||
}
|
||||
|
||||
pub struct LastSyncInfo {
|
||||
pub started_at_iso: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn get_last_sync_time(config: &Config) -> Result<Option<LastSyncInfo>> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let conn = create_connection(&db_path)?;
|
||||
let result = conn.query_row(
|
||||
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
let started_at: i64 = row.get(0)?;
|
||||
let status: String = row.get(1)?;
|
||||
Ok(LastSyncInfo {
|
||||
started_at_iso: ms_to_iso(started_at),
|
||||
status,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(info) => Ok(Some(info)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
// Table may not exist if migrations haven't run yet
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) if msg.contains("no such table") => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_cron_status(info: &CronStatusInfo) {
|
||||
if info.status.installed {
|
||||
println!(
|
||||
" {} lore-sync is installed in crontab",
|
||||
Theme::success().render("Installed")
|
||||
);
|
||||
if let Some(interval) = info.status.interval_minutes {
|
||||
println!(
|
||||
" {} every {} minutes",
|
||||
Theme::dim().render("interval:"),
|
||||
interval
|
||||
);
|
||||
}
|
||||
if let Some(ref binary) = info.status.binary_path {
|
||||
let label = if info.status.binary_mismatch {
|
||||
Theme::warning().render("binary:")
|
||||
} else {
|
||||
Theme::dim().render("binary:")
|
||||
};
|
||||
println!(" {label} {binary}");
|
||||
if info.status.binary_mismatch
|
||||
&& let Some(ref current) = info.status.current_binary
|
||||
{
|
||||
println!(
|
||||
" {}",
|
||||
Theme::warning().render(&format!(" current binary is {current} (mismatch!)"))
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref log) = info.status.log_path {
|
||||
println!(" {} {}", Theme::dim().render("log:"), log.display());
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" {} lore-sync is not installed in crontab",
|
||||
Theme::dim().render("Not installed:")
|
||||
);
|
||||
println!(
|
||||
" {} lore cron install",
|
||||
Theme::dim().render("install with:")
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref last) = info.last_sync {
|
||||
println!(
|
||||
" {} {} ({})",
|
||||
Theme::dim().render("last sync:"),
|
||||
last.started_at_iso,
|
||||
last.status
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronStatusJson {
|
||||
ok: bool,
|
||||
data: CronStatusData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronStatusData {
|
||||
installed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
interval_minutes: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
binary_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
current_binary: Option<String>,
|
||||
binary_mismatch: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
log_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cron_entry: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_sync_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_sync_status: Option<String>,
|
||||
}
|
||||
|
||||
pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) {
|
||||
let output = CronStatusJson {
|
||||
ok: true,
|
||||
data: CronStatusData {
|
||||
installed: info.status.installed,
|
||||
interval_minutes: info.status.interval_minutes,
|
||||
binary_path: info.status.binary_path.clone(),
|
||||
current_binary: info.status.current_binary.clone(),
|
||||
binary_mismatch: info.status.binary_mismatch,
|
||||
log_path: info
|
||||
.status
|
||||
.log_path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string()),
|
||||
cron_entry: info.status.cron_entry.clone(),
|
||||
last_sync_at: info.last_sync.as_ref().map(|s| s.started_at_iso.clone()),
|
||||
last_sync_status: info.last_sync.as_ref().map(|s| s.status.clone()),
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::config::Config;
|
||||
@@ -240,14 +240,14 @@ async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
||||
};
|
||||
};
|
||||
|
||||
let token = match std::env::var(&config.gitlab.token_env_var) {
|
||||
Ok(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
_ => {
|
||||
let token = match config.gitlab.resolve_token() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return GitLabCheck {
|
||||
result: CheckResult {
|
||||
status: CheckStatus::Error,
|
||||
message: Some(format!(
|
||||
"{} not set in environment",
|
||||
"Token not set. Run 'lore token set' or export {}.",
|
||||
config.gitlab.token_env_var
|
||||
)),
|
||||
},
|
||||
@@ -257,6 +257,8 @@ async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
||||
}
|
||||
};
|
||||
|
||||
let source = config.gitlab.token_source().unwrap_or("unknown");
|
||||
|
||||
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
||||
|
||||
match client.get_current_user().await {
|
||||
@@ -264,7 +266,7 @@ async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
||||
result: CheckResult {
|
||||
status: CheckStatus::Ok,
|
||||
message: Some(format!(
|
||||
"{} (authenticated as @{})",
|
||||
"{} (authenticated as @{}, token from {source})",
|
||||
config.gitlab.base_url, user.username
|
||||
)),
|
||||
},
|
||||
@@ -530,7 +532,7 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck {
|
||||
}
|
||||
|
||||
pub fn print_doctor_results(result: &DoctorResult) {
|
||||
println!("\nlore doctor\n");
|
||||
println!();
|
||||
|
||||
print_check("Config", &result.checks.config.result);
|
||||
print_check("Database", &result.checks.database.result);
|
||||
@@ -539,38 +541,61 @@ pub fn print_doctor_results(result: &DoctorResult) {
|
||||
print_check("Ollama", &result.checks.ollama.result);
|
||||
print_check("Logging", &result.checks.logging.result);
|
||||
|
||||
// Count statuses
|
||||
let checks = [
|
||||
&result.checks.config.result,
|
||||
&result.checks.database.result,
|
||||
&result.checks.gitlab.result,
|
||||
&result.checks.projects.result,
|
||||
&result.checks.ollama.result,
|
||||
&result.checks.logging.result,
|
||||
];
|
||||
let passed = checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Ok)
|
||||
.count();
|
||||
let warnings = checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Warning)
|
||||
.count();
|
||||
let failed = checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Error)
|
||||
.count();
|
||||
|
||||
println!();
|
||||
|
||||
let mut summary_parts = Vec::new();
|
||||
if result.success {
|
||||
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
|
||||
if ollama_ok {
|
||||
println!("{}", style("Status: Ready").green());
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Status: Ready").green(),
|
||||
style("(lexical search available, semantic search requires Ollama)").yellow()
|
||||
);
|
||||
}
|
||||
summary_parts.push(Theme::success().render("Ready"));
|
||||
} else {
|
||||
println!("{}", style("Status: Not ready").red());
|
||||
summary_parts.push(Theme::error().render("Not ready"));
|
||||
}
|
||||
summary_parts.push(format!("{passed} passed"));
|
||||
if warnings > 0 {
|
||||
summary_parts.push(Theme::warning().render(&format!("{warnings} warning")));
|
||||
}
|
||||
if failed > 0 {
|
||||
summary_parts.push(Theme::error().render(&format!("{failed} failed")));
|
||||
}
|
||||
println!(" {}", summary_parts.join(" \u{b7} "));
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_check(name: &str, result: &CheckResult) {
|
||||
let symbol = match result.status {
|
||||
CheckStatus::Ok => style("✓").green(),
|
||||
CheckStatus::Warning => style("⚠").yellow(),
|
||||
CheckStatus::Error => style("✗").red(),
|
||||
let icon = match result.status {
|
||||
CheckStatus::Ok => Theme::success().render(Icons::success()),
|
||||
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
|
||||
CheckStatus::Error => Theme::error().render(Icons::error()),
|
||||
};
|
||||
|
||||
let message = result.message.as_deref().unwrap_or("");
|
||||
let message_styled = match result.status {
|
||||
CheckStatus::Ok => message.to_string(),
|
||||
CheckStatus::Warning => style(message).yellow().to_string(),
|
||||
CheckStatus::Error => style(message).red().to_string(),
|
||||
CheckStatus::Warning => Theme::warning().render(message),
|
||||
CheckStatus::Error => Theme::error().render(message),
|
||||
};
|
||||
|
||||
println!(" {symbol} {:<10} {message_styled}", name);
|
||||
println!(" {icon} {:<10} {message_styled}", name);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use console::style;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::config::Config;
|
||||
use crate::core::db::create_connection;
|
||||
@@ -382,7 +382,7 @@ fn extract_drift_topics(description: &str, notes: &[NoteRow], drift_idx: usize)
|
||||
}
|
||||
|
||||
let mut sorted: Vec<(String, usize)> = freq.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
sorted.sort_by_key(|b| std::cmp::Reverse(b.1));
|
||||
|
||||
sorted
|
||||
.into_iter()
|
||||
@@ -420,7 +420,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
"Drift Analysis: {} #{}",
|
||||
response.entity.entity_type, response.entity.iid
|
||||
);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "-".repeat(header.len().min(60)));
|
||||
println!("Title: {}", response.entity.title);
|
||||
println!("Threshold: {:.2}", response.threshold);
|
||||
@@ -428,7 +428,11 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!();
|
||||
|
||||
if response.drift_detected {
|
||||
println!("{}", style("DRIFT DETECTED").red().bold());
|
||||
println!(
|
||||
"{} {}",
|
||||
Theme::error().render(Icons::error()),
|
||||
Theme::error().bold().render("DRIFT DETECTED")
|
||||
);
|
||||
if let Some(dp) = &response.drift_point {
|
||||
println!(
|
||||
" At note #{} by @{} ({}) - similarity {:.2}",
|
||||
@@ -439,7 +443,11 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!(" Topics: {}", response.drift_topics.join(", "));
|
||||
}
|
||||
} else {
|
||||
println!("{}", style("No drift detected").green());
|
||||
println!(
|
||||
"{} {}",
|
||||
Theme::success().render(Icons::success()),
|
||||
Theme::success().render("No drift detected")
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -447,10 +455,10 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
|
||||
if !response.similarity_curve.is_empty() {
|
||||
println!();
|
||||
println!("{}", style("Similarity Curve:").bold());
|
||||
println!("{}", Theme::bold().render("Similarity Curve:"));
|
||||
for pt in &response.similarity_curve {
|
||||
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
|
||||
let bar: String = "#".repeat(bar_len);
|
||||
let bar: String = "\u{2588}".repeat(bar_len);
|
||||
println!(
|
||||
" {:>3} {:.2} {} @{}",
|
||||
pt.note_index, pt.similarity, bar, pt.author
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
@@ -96,16 +96,31 @@ pub async fn run_embed(
|
||||
}
|
||||
|
||||
pub fn print_embed(result: &EmbedCommandResult) {
|
||||
println!("{} Embedding complete", style("done").green().bold(),);
|
||||
if result.docs_embedded == 0 && result.failed == 0 && result.skipped == 0 {
|
||||
println!(
|
||||
"\n {} nothing to embed",
|
||||
Theme::success().bold().render("Embedding")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
" Embedded: {} documents ({} chunks)",
|
||||
result.docs_embedded, result.chunks_embedded
|
||||
"\n {} {} documents ({} chunks)",
|
||||
Theme::success().bold().render("Embedded"),
|
||||
Theme::bold().render(&result.docs_embedded.to_string()),
|
||||
result.chunks_embedded
|
||||
);
|
||||
if result.failed > 0 {
|
||||
println!(" Failed: {}", style(result.failed).red());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::error().render(&format!("{} failed", result.failed))
|
||||
);
|
||||
}
|
||||
if result.skipped > 0 {
|
||||
println!(" Skipped: {}", result.skipped);
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{} skipped", result.skipped))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,5 +137,8 @@ pub fn print_embed_json(result: &EmbedCommandResult, elapsed_ms: u64) {
|
||||
data: result,
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
413
src/cli/commands/file_history.rs
Normal file
413
src/cli/commands/file_history.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::Result;
|
||||
use crate::core::file_history::resolve_rename_chain;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
/// Maximum rename chain BFS depth.
|
||||
const MAX_RENAME_HOPS: usize = 10;
|
||||
|
||||
/// A single MR that touched the file.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileHistoryMr {
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author_username: String,
|
||||
pub change_type: String,
|
||||
pub merged_at_iso: Option<String>,
|
||||
pub updated_at_iso: String,
|
||||
pub merge_commit_sha: Option<String>,
|
||||
pub web_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A DiffNote discussion snippet on the file.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileDiscussion {
|
||||
pub discussion_id: String,
|
||||
pub author_username: String,
|
||||
pub body_snippet: String,
|
||||
pub path: String,
|
||||
pub created_at_iso: String,
|
||||
}
|
||||
|
||||
/// Full result of a file-history query.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileHistoryResult {
|
||||
pub path: String,
|
||||
pub rename_chain: Vec<String>,
|
||||
pub renames_followed: bool,
|
||||
pub merge_requests: Vec<FileHistoryMr>,
|
||||
pub discussions: Vec<FileDiscussion>,
|
||||
pub total_mrs: usize,
|
||||
pub paths_searched: usize,
|
||||
/// Diagnostic hints explaining why results may be empty.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub hints: Vec<String>,
|
||||
}
|
||||
|
||||
/// Run the file-history query.
|
||||
pub fn run_file_history(
|
||||
config: &Config,
|
||||
path: &str,
|
||||
project: Option<&str>,
|
||||
no_follow_renames: bool,
|
||||
merged_only: bool,
|
||||
include_discussions: bool,
|
||||
limit: usize,
|
||||
) -> Result<FileHistoryResult> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let project_id = project.map(|p| resolve_project(&conn, p)).transpose()?;
|
||||
|
||||
// Resolve rename chain unless disabled
|
||||
let (all_paths, renames_followed) = if no_follow_renames {
|
||||
(vec![path.to_string()], false)
|
||||
} else if let Some(pid) = project_id {
|
||||
let chain = resolve_rename_chain(&conn, pid, path, MAX_RENAME_HOPS)?;
|
||||
let followed = chain.len() > 1;
|
||||
(chain, followed)
|
||||
} else {
|
||||
// Without a project scope, can't resolve renames (need project_id)
|
||||
(vec![path.to_string()], false)
|
||||
};
|
||||
|
||||
let paths_searched = all_paths.len();
|
||||
|
||||
info!(
|
||||
paths = paths_searched,
|
||||
renames_followed, "file-history: resolved {} path(s) for '{}'", paths_searched, path
|
||||
);
|
||||
|
||||
// Build placeholders for IN clause
|
||||
let placeholders: Vec<String> = (0..all_paths.len())
|
||||
.map(|i| format!("?{}", i + 2))
|
||||
.collect();
|
||||
let in_clause = placeholders.join(", ");
|
||||
|
||||
let merged_filter = if merged_only {
|
||||
" AND mr.state = 'merged'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let project_filter = if project_id.is_some() {
|
||||
"AND mfc.project_id = ?1"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT \
|
||||
mr.iid, mr.title, mr.state, mr.author_username, \
|
||||
mfc.change_type, mr.merged_at, mr.updated_at, mr.merge_commit_sha, mr.web_url \
|
||||
FROM mr_file_changes mfc \
|
||||
JOIN merge_requests mr ON mr.id = mfc.merge_request_id \
|
||||
WHERE mfc.new_path IN ({in_clause}) {project_filter} {merged_filter} \
|
||||
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \
|
||||
LIMIT ?{}",
|
||||
all_paths.len() + 2
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
// Bind parameters: ?1 = project_id (or 0 placeholder), ?2..?N+1 = paths, ?N+2 = limit
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
params.push(Box::new(project_id.unwrap_or(0)));
|
||||
for p in &all_paths {
|
||||
params.push(Box::new(p.clone()));
|
||||
}
|
||||
params.push(Box::new(limit as i64));
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let merge_requests: Vec<FileHistoryMr> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let merged_at: Option<i64> = row.get(5)?;
|
||||
let updated_at: i64 = row.get(6)?;
|
||||
Ok(FileHistoryMr {
|
||||
iid: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
state: row.get(2)?,
|
||||
author_username: row.get(3)?,
|
||||
change_type: row.get(4)?,
|
||||
merged_at_iso: merged_at.map(ms_to_iso),
|
||||
updated_at_iso: ms_to_iso(updated_at),
|
||||
merge_commit_sha: row.get(7)?,
|
||||
web_url: row.get(8)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
let total_mrs = merge_requests.len();
|
||||
|
||||
info!(
|
||||
mr_count = total_mrs,
|
||||
"file-history: found {} MR(s) touching '{}'", total_mrs, path
|
||||
);
|
||||
|
||||
// Optionally fetch DiffNote discussions on this file
|
||||
let discussions = if include_discussions && !merge_requests.is_empty() {
|
||||
let discs = fetch_file_discussions(&conn, &all_paths, project_id)?;
|
||||
info!(
|
||||
discussion_count = discs.len(),
|
||||
"file-history: found {} discussion(s)",
|
||||
discs.len()
|
||||
);
|
||||
discs
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Build diagnostic hints when no results found
|
||||
let hints = if total_mrs == 0 {
|
||||
build_file_history_hints(&conn, project_id, &all_paths)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(FileHistoryResult {
|
||||
path: path.to_string(),
|
||||
rename_chain: all_paths,
|
||||
renames_followed,
|
||||
merge_requests,
|
||||
discussions,
|
||||
total_mrs,
|
||||
paths_searched,
|
||||
hints,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch DiffNote discussions that reference the given file paths.
|
||||
fn fetch_file_discussions(
|
||||
conn: &rusqlite::Connection,
|
||||
paths: &[String],
|
||||
project_id: Option<i64>,
|
||||
) -> Result<Vec<FileDiscussion>> {
|
||||
let placeholders: Vec<String> = (0..paths.len()).map(|i| format!("?{}", i + 2)).collect();
|
||||
let in_clause = placeholders.join(", ");
|
||||
|
||||
let project_filter = if project_id.is_some() {
|
||||
"AND d.project_id = ?1"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT d.gitlab_discussion_id, n.author_username, n.body, n.position_new_path, n.created_at \
|
||||
FROM notes n \
|
||||
JOIN discussions d ON d.id = n.discussion_id \
|
||||
WHERE n.position_new_path IN ({in_clause}) {project_filter} \
|
||||
AND n.is_system = 0 \
|
||||
ORDER BY n.created_at DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
params.push(Box::new(project_id.unwrap_or(0)));
|
||||
for p in paths {
|
||||
params.push(Box::new(p.clone()));
|
||||
}
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let discussions: Vec<FileDiscussion> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let body: String = row.get(2)?;
|
||||
let snippet = if body.len() > 200 {
|
||||
format!("{}...", &body[..body.floor_char_boundary(200)])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let created_at: i64 = row.get(4)?;
|
||||
Ok(FileDiscussion {
|
||||
discussion_id: row.get(0)?,
|
||||
author_username: row.get(1)?,
|
||||
body_snippet: snippet,
|
||||
path: row.get(3)?,
|
||||
created_at_iso: ms_to_iso(created_at),
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(discussions)
|
||||
}
|
||||
|
||||
/// Build diagnostic hints explaining why a file-history query returned no results.
|
||||
fn build_file_history_hints(
|
||||
conn: &rusqlite::Connection,
|
||||
project_id: Option<i64>,
|
||||
paths: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
let mut hints = Vec::new();
|
||||
|
||||
// Check if mr_file_changes has ANY rows for this project
|
||||
let has_file_changes: bool = if let Some(pid) = project_id {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM mr_file_changes WHERE project_id = ?1 LIMIT 1)",
|
||||
rusqlite::params![pid],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM mr_file_changes LIMIT 1)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
|
||||
if !has_file_changes {
|
||||
hints.push(
|
||||
"No MR file changes have been synced yet. Run 'lore sync' to fetch file change data."
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(hints);
|
||||
}
|
||||
|
||||
// File changes exist but none match these paths
|
||||
let path_list = paths
|
||||
.iter()
|
||||
.map(|p| format!("'{p}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
hints.push(format!(
|
||||
"Searched paths [{}] were not found in MR file changes. \
|
||||
The file may predate the sync window or use a different path.",
|
||||
path_list
|
||||
));
|
||||
|
||||
Ok(hints)
|
||||
}
|
||||
|
||||
// ── Human output ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_file_history(result: &FileHistoryResult) {
|
||||
// Header
|
||||
let paths_info = if result.paths_searched > 1 {
|
||||
format!(
|
||||
" (via {} paths, {} MRs)",
|
||||
result.paths_searched, result.total_mrs
|
||||
)
|
||||
} else {
|
||||
format!(" ({} MRs)", result.total_mrs)
|
||||
};
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("File History: {}{}", result.path, paths_info))
|
||||
);
|
||||
|
||||
// Rename chain
|
||||
if result.renames_followed && result.rename_chain.len() > 1 {
|
||||
let chain_str: Vec<&str> = result.rename_chain.iter().map(String::as_str).collect();
|
||||
println!(
|
||||
" Rename chain: {}",
|
||||
Theme::dim().render(&chain_str.join(" -> "))
|
||||
);
|
||||
}
|
||||
|
||||
if result.merge_requests.is_empty() {
|
||||
println!(
|
||||
"\n {} {}",
|
||||
Icons::info(),
|
||||
Theme::dim().render("No merge requests found touching this file.")
|
||||
);
|
||||
if !result.renames_followed && result.rename_chain.len() == 1 {
|
||||
println!(
|
||||
" {} Searched: {}",
|
||||
Icons::info(),
|
||||
Theme::dim().render(&result.rename_chain[0])
|
||||
);
|
||||
}
|
||||
for hint in &result.hints {
|
||||
println!(" {} {}", Icons::info(), Theme::dim().render(hint));
|
||||
}
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
for mr in &result.merge_requests {
|
||||
let (icon, state_style) = match mr.state.as_str() {
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"closed" => (Icons::mr_closed(), Theme::warning()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
|
||||
let date = mr
|
||||
.merged_at_iso
|
||||
.as_deref()
|
||||
.or(Some(mr.updated_at_iso.as_str()))
|
||||
.unwrap_or("")
|
||||
.split('T')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
println!(
|
||||
" {} {} {} {} @{} {} {}",
|
||||
icon,
|
||||
Theme::accent().render(&format!("!{}", mr.iid)),
|
||||
render::truncate(&mr.title, 50),
|
||||
state_style.render(&mr.state),
|
||||
mr.author_username,
|
||||
date,
|
||||
Theme::dim().render(&mr.change_type),
|
||||
);
|
||||
}
|
||||
|
||||
// Discussions
|
||||
if !result.discussions.is_empty() {
|
||||
println!(
|
||||
"\n {} File discussions ({}):",
|
||||
Icons::note(),
|
||||
result.discussions.len()
|
||||
);
|
||||
for d in &result.discussions {
|
||||
let date = d.created_at_iso.split('T').next().unwrap_or("");
|
||||
println!(
|
||||
" @{} ({}) [{}]: {}",
|
||||
d.author_username,
|
||||
date,
|
||||
Theme::dim().render(&d.path),
|
||||
d.body_snippet
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
// ── Robot (JSON) output ─────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"path": result.path,
|
||||
"rename_chain": if result.renames_followed { Some(&result.rename_chain) } else { None },
|
||||
"merge_requests": result.merge_requests,
|
||||
"discussions": if result.discussions.is_empty() { None } else { Some(&result.discussions) },
|
||||
},
|
||||
"meta": {
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"total_mrs": result.total_mrs,
|
||||
"renames_followed": result.renames_followed,
|
||||
"paths_searched": result.paths_searched,
|
||||
"hints": if result.hints.is_empty() { None } else { Some(&result.hints) },
|
||||
}
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
use tracing::info;
|
||||
@@ -39,6 +39,7 @@ pub fn run_generate_docs(
|
||||
result.seeded += seed_dirty(&conn, SourceType::Issue, project_filter)?;
|
||||
result.seeded += seed_dirty(&conn, SourceType::MergeRequest, project_filter)?;
|
||||
result.seeded += seed_dirty(&conn, SourceType::Discussion, project_filter)?;
|
||||
result.seeded += seed_dirty_notes(&conn, project_filter)?;
|
||||
}
|
||||
|
||||
let regen =
|
||||
@@ -67,6 +68,10 @@ fn seed_dirty(
|
||||
SourceType::Issue => "issues",
|
||||
SourceType::MergeRequest => "merge_requests",
|
||||
SourceType::Discussion => "discussions",
|
||||
SourceType::Note => {
|
||||
// NOTE-2E will implement seed_dirty_notes separately (needs is_system filter)
|
||||
unreachable!("Note seeding handled by seed_dirty_notes, not seed_dirty")
|
||||
}
|
||||
};
|
||||
let type_str = source_type.as_str();
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
@@ -125,25 +130,95 @@ fn seed_dirty(
|
||||
Ok(total_seeded)
|
||||
}
|
||||
|
||||
fn seed_dirty_notes(conn: &Connection, project_filter: Option<&str>) -> Result<usize> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let mut total_seeded: usize = 0;
|
||||
let mut last_id: i64 = 0;
|
||||
|
||||
loop {
|
||||
let inserted = if let Some(project) = project_filter {
|
||||
let project_id = resolve_project(conn, project)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at, attempt_count, last_attempt_at, last_error, next_attempt_at)
|
||||
SELECT 'note', id, ?1, 0, NULL, NULL, NULL
|
||||
FROM notes WHERE id > ?2 AND project_id = ?3 AND is_system = 0 ORDER BY id LIMIT ?4
|
||||
ON CONFLICT(source_type, source_id) DO NOTHING",
|
||||
rusqlite::params![now, last_id, project_id, FULL_MODE_CHUNK_SIZE],
|
||||
)?
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at, attempt_count, last_attempt_at, last_error, next_attempt_at)
|
||||
SELECT 'note', id, ?1, 0, NULL, NULL, NULL
|
||||
FROM notes WHERE id > ?2 AND is_system = 0 ORDER BY id LIMIT ?3
|
||||
ON CONFLICT(source_type, source_id) DO NOTHING",
|
||||
rusqlite::params![now, last_id, FULL_MODE_CHUNK_SIZE],
|
||||
)?
|
||||
};
|
||||
|
||||
if inserted == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let max_id: i64 = conn.query_row(
|
||||
"SELECT MAX(id) FROM (SELECT id FROM notes WHERE id > ?1 AND is_system = 0 ORDER BY id LIMIT ?2)",
|
||||
rusqlite::params![last_id, FULL_MODE_CHUNK_SIZE],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
total_seeded += inserted;
|
||||
last_id = max_id;
|
||||
}
|
||||
|
||||
info!(
|
||||
source_type = "note",
|
||||
seeded = total_seeded,
|
||||
"Seeded dirty_sources"
|
||||
);
|
||||
|
||||
Ok(total_seeded)
|
||||
}
|
||||
|
||||
pub fn print_generate_docs(result: &GenerateDocsResult) {
|
||||
let mode = if result.full_mode {
|
||||
"full"
|
||||
} else {
|
||||
"incremental"
|
||||
};
|
||||
|
||||
if result.regenerated == 0 && result.errored == 0 {
|
||||
println!(
|
||||
"\n {} no documents to update ({})",
|
||||
Theme::success().bold().render("Docs"),
|
||||
mode
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Headline
|
||||
println!(
|
||||
"{} Document generation complete ({})",
|
||||
style("done").green().bold(),
|
||||
"\n {} {} documents ({})",
|
||||
Theme::success().bold().render("Generated"),
|
||||
Theme::bold().render(&result.regenerated.to_string()),
|
||||
mode
|
||||
);
|
||||
|
||||
if result.full_mode {
|
||||
println!(" Seeded: {}", result.seeded);
|
||||
// Detail line: compact middle-dot format, zero-suppressed
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if result.full_mode && result.seeded > 0 {
|
||||
details.push(format!("{} seeded", result.seeded));
|
||||
}
|
||||
if result.unchanged > 0 {
|
||||
details.push(format!("{} unchanged", result.unchanged));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||
}
|
||||
println!(" Regenerated: {}", result.regenerated);
|
||||
println!(" Unchanged: {}", result.unchanged);
|
||||
if result.errored > 0 {
|
||||
println!(" Errored: {}", style(result.errored).red());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::error().render(&format!("{} errored", result.errored))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,5 +259,86 @@ pub fn print_generate_docs_json(result: &GenerateDocsResult, elapsed_ms: u64) {
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (1, 100, 'group/project', 'https://gitlab.com/group/project')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 1, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_note(conn: &Connection, id: i64, gitlab_id: i64, is_system: bool) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'alice', 'note body', 1000, 2000, 3000, ?3)",
|
||||
rusqlite::params![id, gitlab_id, is_system as i32],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_seed_includes_notes() {
|
||||
let conn = setup_db();
|
||||
insert_note(&conn, 1, 101, false);
|
||||
insert_note(&conn, 2, 102, false);
|
||||
insert_note(&conn, 3, 103, false);
|
||||
insert_note(&conn, 4, 104, true); // system note — should be excluded
|
||||
|
||||
let seeded = seed_dirty_notes(&conn, None).unwrap();
|
||||
assert_eq!(seeded, 3);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_count_stable_after_second_generate_docs_full() {
|
||||
let conn = setup_db();
|
||||
insert_note(&conn, 1, 101, false);
|
||||
insert_note(&conn, 2, 102, false);
|
||||
|
||||
let first = seed_dirty_notes(&conn, None).unwrap();
|
||||
assert_eq!(first, 2);
|
||||
|
||||
// Second run should be idempotent (ON CONFLICT DO NOTHING)
|
||||
let second = seed_dirty_notes(&conn, None).unwrap();
|
||||
assert_eq!(second, 0);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
@@ -46,10 +46,27 @@ pub struct IngestResult {
|
||||
pub mr_diffs_failed: usize,
|
||||
pub status_enrichment_errors: usize,
|
||||
pub status_enrichment_projects: Vec<ProjectStatusEnrichment>,
|
||||
pub project_summaries: Vec<ProjectSummary>,
|
||||
}
|
||||
|
||||
/// Per-project summary for display in stage completion sub-rows.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProjectSummary {
|
||||
pub path: String,
|
||||
pub items_upserted: usize,
|
||||
pub discussions_synced: usize,
|
||||
pub events_fetched: usize,
|
||||
pub events_failed: usize,
|
||||
pub statuses_enriched: usize,
|
||||
pub statuses_seen: usize,
|
||||
pub status_errors: usize,
|
||||
pub mr_diffs_fetched: usize,
|
||||
pub mr_diffs_failed: usize,
|
||||
}
|
||||
|
||||
/// Per-project status enrichment result, collected during ingestion.
|
||||
pub struct ProjectStatusEnrichment {
|
||||
pub path: String,
|
||||
pub mode: String,
|
||||
pub reason: Option<String>,
|
||||
pub seen: usize,
|
||||
@@ -276,10 +293,7 @@ async fn run_ingest_inner(
|
||||
);
|
||||
lock.acquire(force)?;
|
||||
|
||||
let token =
|
||||
std::env::var(&config.gitlab.token_env_var).map_err(|_| LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
})?;
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
|
||||
let client = GitLabClient::new(
|
||||
&config.gitlab.base_url,
|
||||
@@ -293,7 +307,7 @@ async fn run_ingest_inner(
|
||||
if display.show_text {
|
||||
println!(
|
||||
"{}",
|
||||
style("Full sync: resetting cursors to fetch all data...").yellow()
|
||||
Theme::warning().render("Full sync: resetting cursors to fetch all data...")
|
||||
);
|
||||
}
|
||||
for (local_project_id, _, path) in &projects {
|
||||
@@ -341,7 +355,10 @@ async fn run_ingest_inner(
|
||||
"merge requests"
|
||||
};
|
||||
if display.show_text {
|
||||
println!("{}", style(format!("Ingesting {type_label}...")).blue());
|
||||
println!(
|
||||
"{}",
|
||||
Theme::info().render(&format!("Ingesting {type_label}..."))
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -385,11 +402,11 @@ async fn run_ingest_inner(
|
||||
let s = multi.add(ProgressBar::new_spinner());
|
||||
s.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.blue} {msg}")
|
||||
.template("{spinner:.cyan} {msg}")
|
||||
.unwrap(),
|
||||
);
|
||||
s.set_message(format!("Fetching {type_label} from {path}..."));
|
||||
s.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
s.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
s
|
||||
};
|
||||
|
||||
@@ -400,12 +417,13 @@ async fn run_ingest_inner(
|
||||
b.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
" {spinner:.blue} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}",
|
||||
" {spinner:.dim} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||
);
|
||||
b.set_prefix(path.clone());
|
||||
b.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
b
|
||||
};
|
||||
|
||||
@@ -442,7 +460,7 @@ async fn run_ingest_inner(
|
||||
spinner_clone.finish_and_clear();
|
||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Syncing discussions... (0/{agg_total})"
|
||||
));
|
||||
@@ -462,7 +480,7 @@ async fn run_ingest_inner(
|
||||
spinner_clone.finish_and_clear();
|
||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Syncing discussions... (0/{agg_total})"
|
||||
));
|
||||
@@ -483,11 +501,11 @@ async fn run_ingest_inner(
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" {spinner:.blue} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dim}] {pos}/{len}")
|
||||
.template(" {spinner:.dim} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||
);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
agg_events_total_clone.fetch_add(total, Ordering::Relaxed);
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching resource events...".to_string()
|
||||
@@ -507,7 +525,7 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::ClosesIssuesFetchStarted { total } => {
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching closes-issues references...".to_string()
|
||||
);
|
||||
@@ -521,7 +539,7 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::MrDiffsFetchStarted { total } => {
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching MR file changes...".to_string()
|
||||
);
|
||||
@@ -532,35 +550,37 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
||||
disc_bar_clone.finish_and_clear();
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentStarted => {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: Enriching work item statuses..."
|
||||
));
|
||||
ProgressEvent::StatusEnrichmentStarted { total } => {
|
||||
spinner_clone.finish_and_clear();
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" {spinner:.dim} {prefix:.cyan} Statuses [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
|
||||
.unwrap()
|
||||
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||
);
|
||||
disc_bar_clone.set_prefix(path_for_cb.clone());
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(
|
||||
"Enriching work item statuses...".to_string()
|
||||
);
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: Fetching statuses... ({items_so_far} work items)"
|
||||
));
|
||||
disc_bar_clone.set_position(items_so_far as u64);
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Enriching work item statuses... ({items_so_far} fetched)"
|
||||
));
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentWriting { total } => {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: Writing {total} statuses..."
|
||||
));
|
||||
disc_bar_clone.set_message(format!("Writing {total} statuses..."));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Writing {total} work item statuses..."
|
||||
));
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
|
||||
disc_bar_clone.finish_and_clear();
|
||||
if enriched > 0 || cleared > 0 {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: {enriched} statuses enriched, {cleared} cleared"
|
||||
));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Status enrichment: {enriched} enriched, {cleared} cleared"
|
||||
));
|
||||
@@ -643,6 +663,7 @@ async fn run_ingest_inner(
|
||||
total
|
||||
.status_enrichment_projects
|
||||
.push(ProjectStatusEnrichment {
|
||||
path: path.clone(),
|
||||
mode: result.status_enrichment_mode.clone(),
|
||||
reason: result.status_unsupported_reason.clone(),
|
||||
seen: result.statuses_seen,
|
||||
@@ -653,6 +674,19 @@ async fn run_ingest_inner(
|
||||
first_partial_error: result.first_partial_error.clone(),
|
||||
error: result.status_enrichment_error.clone(),
|
||||
});
|
||||
total.project_summaries.push(ProjectSummary {
|
||||
path: path.clone(),
|
||||
items_upserted: result.issues_upserted,
|
||||
discussions_synced: result.discussions_fetched,
|
||||
events_fetched: result.resource_events_fetched,
|
||||
events_failed: result.resource_events_failed,
|
||||
statuses_enriched: result.statuses_enriched,
|
||||
statuses_seen: result.statuses_seen,
|
||||
status_errors: result.partial_error_count
|
||||
+ usize::from(result.status_enrichment_error.is_some()),
|
||||
mr_diffs_fetched: 0,
|
||||
mr_diffs_failed: 0,
|
||||
});
|
||||
}
|
||||
Ok(ProjectIngestOutcome::Mrs {
|
||||
ref path,
|
||||
@@ -676,6 +710,18 @@ async fn run_ingest_inner(
|
||||
total.resource_events_failed += result.resource_events_failed;
|
||||
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
||||
total.mr_diffs_failed += result.mr_diffs_failed;
|
||||
total.project_summaries.push(ProjectSummary {
|
||||
path: path.clone(),
|
||||
items_upserted: result.mrs_upserted,
|
||||
discussions_synced: result.discussions_fetched,
|
||||
events_fetched: result.resource_events_fetched,
|
||||
events_failed: result.resource_events_failed,
|
||||
statuses_enriched: 0,
|
||||
statuses_seen: 0,
|
||||
status_errors: 0,
|
||||
mr_diffs_fetched: result.mr_diffs_fetched,
|
||||
mr_diffs_failed: result.mr_diffs_failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,7 +792,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
|
||||
|
||||
println!(
|
||||
" {}: {} issues fetched{}",
|
||||
style(path).cyan(),
|
||||
Theme::info().render(path),
|
||||
result.issues_upserted,
|
||||
labels_str
|
||||
);
|
||||
@@ -761,7 +807,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
|
||||
if result.issues_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
" {} unchanged issues (discussion sync skipped)",
|
||||
style(result.issues_skipped_discussion_sync).dim()
|
||||
Theme::dim().render(&result.issues_skipped_discussion_sync.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -784,7 +830,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
||||
|
||||
println!(
|
||||
" {}: {} MRs fetched{}{}",
|
||||
style(path).cyan(),
|
||||
Theme::info().render(path),
|
||||
result.mrs_upserted,
|
||||
labels_str,
|
||||
assignees_str
|
||||
@@ -808,7 +854,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
||||
if result.mrs_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
" {} unchanged MRs (discussion sync skipped)",
|
||||
style(result.mrs_skipped_discussion_sync).dim()
|
||||
Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -933,7 +979,10 @@ pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) {
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_ingest_summary(result: &IngestResult) {
|
||||
@@ -942,21 +991,19 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
if result.resource_type == "issues" {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::success().render(&format!(
|
||||
"Total: {} issues, {} discussions, {} notes",
|
||||
result.issues_upserted, result.discussions_fetched, result.notes_upserted
|
||||
))
|
||||
.green()
|
||||
);
|
||||
|
||||
if result.issues_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::dim().render(&format!(
|
||||
"Skipped discussion sync for {} unchanged issues.",
|
||||
result.issues_skipped_discussion_sync
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -968,24 +1015,22 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::success().render(&format!(
|
||||
"Total: {} MRs, {} discussions, {} notes{}",
|
||||
result.mrs_upserted,
|
||||
result.discussions_fetched,
|
||||
result.notes_upserted,
|
||||
diffnotes_str
|
||||
))
|
||||
.green()
|
||||
);
|
||||
|
||||
if result.mrs_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::dim().render(&format!(
|
||||
"Skipped discussion sync for {} unchanged MRs.",
|
||||
result.mrs_skipped_discussion_sync
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1006,8 +1051,8 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Dry Run Preview").cyan().bold(),
|
||||
style("(no changes will be made)").yellow()
|
||||
Theme::info().bold().render("Dry Run Preview"),
|
||||
Theme::warning().render("(no changes will be made)")
|
||||
);
|
||||
println!();
|
||||
|
||||
@@ -1017,27 +1062,31 @@ pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
||||
"merge requests"
|
||||
};
|
||||
|
||||
println!(" Resource type: {}", style(type_label).white().bold());
|
||||
println!(" Resource type: {}", Theme::bold().render(type_label));
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if preview.sync_mode == "full" {
|
||||
style("full (all data will be re-fetched)").yellow()
|
||||
Theme::warning().render("full (all data will be re-fetched)")
|
||||
} else {
|
||||
style("incremental (only changes since last sync)").green()
|
||||
Theme::success().render("incremental (only changes since last sync)")
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", preview.projects.len());
|
||||
println!();
|
||||
|
||||
println!("{}", style("Projects to sync:").cyan().bold());
|
||||
println!("{}", Theme::info().bold().render("Projects to sync:"));
|
||||
for project in &preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
Theme::warning().render("initial sync")
|
||||
} else {
|
||||
style("incremental").green()
|
||||
Theme::success().render("incremental")
|
||||
};
|
||||
|
||||
println!(" {} ({})", style(&project.path).white(), sync_status);
|
||||
println!(
|
||||
" {} ({})",
|
||||
Theme::bold().render(&project.path),
|
||||
sync_status
|
||||
);
|
||||
println!(" Existing {}: {}", type_label, project.existing_count);
|
||||
|
||||
if let Some(ref last_synced) = project.last_synced {
|
||||
@@ -1060,5 +1109,8 @@ pub fn print_dry_run_preview_json(preview: &DryRunPreview) {
|
||||
data: preview.clone(),
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::fs;
|
||||
use std::io::{IsTerminal, Read};
|
||||
|
||||
use crate::core::config::{MinimalConfig, MinimalGitLabConfig, ProjectConfig};
|
||||
use crate::core::config::{Config, MinimalConfig, MinimalGitLabConfig, ProjectConfig};
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::{get_config_path, get_data_dir};
|
||||
use crate::core::paths::{ensure_config_permissions, get_config_path, get_data_dir};
|
||||
use crate::gitlab::{GitLabClient, GitLabProject};
|
||||
|
||||
pub struct InitInputs {
|
||||
@@ -172,3 +173,141 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
|
||||
default_project: inputs.default_project,
|
||||
})
|
||||
}
|
||||
|
||||
// ── token set / show ──
|
||||
|
||||
pub struct TokenSetResult {
|
||||
pub username: String,
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
pub struct TokenShowResult {
|
||||
pub token: String,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Read token from --token flag or stdin, validate against GitLab, store in config.
|
||||
pub async fn run_token_set(
|
||||
config_path_override: Option<&str>,
|
||||
token_arg: Option<String>,
|
||||
) -> Result<TokenSetResult> {
|
||||
let config_path = get_config_path(config_path_override);
|
||||
|
||||
if !config_path.exists() {
|
||||
return Err(LoreError::ConfigNotFound {
|
||||
path: config_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve token value: flag > stdin > error
|
||||
let token = if let Some(t) = token_arg {
|
||||
t.trim().to_string()
|
||||
} else if !std::io::stdin().is_terminal() {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to read token from stdin: {e}")))?;
|
||||
buf.trim().to_string()
|
||||
} else {
|
||||
return Err(LoreError::Other(
|
||||
"No token provided. Use --token or pipe to stdin.".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(LoreError::Other("Token cannot be empty.".to_string()));
|
||||
}
|
||||
|
||||
// Load config to get the base URL for validation
|
||||
let config = Config::load(config_path_override)?;
|
||||
|
||||
// Validate token against GitLab
|
||||
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
||||
let user = client.get_current_user().await.map_err(|e| {
|
||||
if matches!(e, LoreError::GitLabAuthFailed) {
|
||||
LoreError::Other("Token validation failed: authentication rejected by GitLab.".into())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
|
||||
// Read config as raw JSON, insert token, write back
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to read config file: {e}")))?;
|
||||
|
||||
let mut json: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid {
|
||||
details: format!("Invalid JSON in config file: {e}"),
|
||||
})?;
|
||||
|
||||
json["gitlab"]["token"] = serde_json::Value::String(token);
|
||||
|
||||
let output = serde_json::to_string_pretty(&json)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to serialize config: {e}")))?;
|
||||
fs::write(&config_path, format!("{output}\n"))?;
|
||||
|
||||
// Enforce permissions
|
||||
ensure_config_permissions(&config_path);
|
||||
|
||||
Ok(TokenSetResult {
|
||||
username: user.username,
|
||||
config_path: config_path.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Show the current token (masked or unmasked) and its source.
|
||||
pub fn run_token_show(config_path_override: Option<&str>, unmask: bool) -> Result<TokenShowResult> {
|
||||
let config = Config::load(config_path_override)?;
|
||||
|
||||
let source = config
|
||||
.gitlab
|
||||
.token_source()
|
||||
.ok_or_else(|| LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
})?;
|
||||
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
|
||||
let display_token = if unmask { token } else { mask_token(&token) };
|
||||
|
||||
Ok(TokenShowResult {
|
||||
token: display_token,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_token(token: &str) -> String {
|
||||
let len = token.len();
|
||||
if len <= 8 {
|
||||
"*".repeat(len)
|
||||
} else {
|
||||
let visible = &token[..4];
|
||||
format!("{visible}{}", "*".repeat(len - 4))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_token_hides_short_tokens_completely() {
|
||||
assert_eq!(mask_token(""), "");
|
||||
assert_eq!(mask_token("a"), "*");
|
||||
assert_eq!(mask_token("abcd"), "****");
|
||||
assert_eq!(mask_token("abcdefgh"), "********");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_token_reveals_first_four_chars_for_long_tokens() {
|
||||
assert_eq!(mask_token("abcdefghi"), "abcd*****");
|
||||
assert_eq!(mask_token("glpat-xyzABC123456"), "glpa**************");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_token_boundary_at_nine_chars() {
|
||||
// 8 chars → fully masked, 9 chars → first 4 visible
|
||||
assert_eq!(mask_token("12345678"), "********");
|
||||
assert_eq!(mask_token("123456789"), "1234*****");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
|
||||
use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -6,41 +6,10 @@ use crate::Config;
|
||||
use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields};
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::path_resolver::escape_like as note_escape_like;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::{ms_to_iso, now_ms, parse_since};
|
||||
|
||||
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
|
||||
let cell = Cell::new(content);
|
||||
if console::colors_enabled() {
|
||||
cell.fg(color)
|
||||
} else {
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell {
|
||||
if !console::colors_enabled() {
|
||||
return Cell::new(content);
|
||||
}
|
||||
let Some(hex) = hex else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() != 6 {
|
||||
return Cell::new(content);
|
||||
}
|
||||
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
Cell::new(content).fg(Color::Rgb { r, g, b })
|
||||
}
|
||||
use crate::core::time::{ms_to_iso, parse_since};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueListRow {
|
||||
@@ -668,60 +637,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
||||
Ok(MrListResult { mrs, total_count })
|
||||
}
|
||||
|
||||
fn format_relative_time(ms_epoch: i64) -> String {
|
||||
let now = now_ms();
|
||||
let diff = now - ms_epoch;
|
||||
|
||||
if diff < 0 {
|
||||
return "in the future".to_string();
|
||||
}
|
||||
|
||||
match diff {
|
||||
d if d < 60_000 => "just now".to_string(),
|
||||
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
||||
d if d < 86_400_000 => {
|
||||
let n = d / 3_600_000;
|
||||
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
|
||||
}
|
||||
d if d < 604_800_000 => {
|
||||
let n = d / 86_400_000;
|
||||
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
|
||||
}
|
||||
d if d < 2_592_000_000 => {
|
||||
let n = d / 604_800_000;
|
||||
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
|
||||
}
|
||||
_ => {
|
||||
let n = diff / 2_592_000_000;
|
||||
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
||||
if s.chars().count() <= max_width {
|
||||
s.to_string()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_labels(labels: &[String], max_shown: usize) -> String {
|
||||
if labels.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
|
||||
let overflow = labels.len().saturating_sub(max_shown);
|
||||
|
||||
if overflow > 0 {
|
||||
format!("[{} +{}]", shown.join(", "), overflow)
|
||||
} else {
|
||||
format!("[{}]", shown.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_assignees(assignees: &[String]) -> String {
|
||||
if assignees.is_empty() {
|
||||
return "-".to_string();
|
||||
@@ -731,7 +646,7 @@ fn format_assignees(assignees: &[String]) -> String {
|
||||
let shown: Vec<String> = assignees
|
||||
.iter()
|
||||
.take(max_shown)
|
||||
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10)))
|
||||
.map(|s| format!("@{}", render::truncate(s, 10)))
|
||||
.collect();
|
||||
let overflow = assignees.len().saturating_sub(max_shown);
|
||||
|
||||
@@ -742,21 +657,23 @@ fn format_assignees(assignees: &[String]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_discussions(total: i64, unresolved: i64) -> String {
|
||||
fn format_discussions(total: i64, unresolved: i64) -> StyledCell {
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
return StyledCell::plain(String::new());
|
||||
}
|
||||
|
||||
if unresolved > 0 {
|
||||
format!("{total}/{unresolved}!")
|
||||
let text = format!("{total}/");
|
||||
let warn = Theme::warning().render(&format!("{unresolved}!"));
|
||||
StyledCell::plain(format!("{text}{warn}"))
|
||||
} else {
|
||||
format!("{total}")
|
||||
StyledCell::plain(format!("{total}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
|
||||
let full = format!("{} <- {}", target, source);
|
||||
truncate_with_ellipsis(&full, max_width)
|
||||
render::truncate(&full, max_width)
|
||||
}
|
||||
|
||||
pub fn print_list_issues(result: &ListResult) {
|
||||
@@ -766,71 +683,64 @@ pub fn print_list_issues(result: &ListResult) {
|
||||
}
|
||||
|
||||
println!(
|
||||
"Issues (showing {} of {})\n",
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Issues"),
|
||||
result.issues.len(),
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
|
||||
|
||||
let mut header = vec![
|
||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
||||
Cell::new("State").add_attribute(Attribute::Bold),
|
||||
];
|
||||
let mut headers = vec!["IID", "Title", "State"];
|
||||
if has_any_status {
|
||||
header.push(Cell::new("Status").add_attribute(Attribute::Bold));
|
||||
headers.push("Status");
|
||||
}
|
||||
header.extend([
|
||||
Cell::new("Assignee").add_attribute(Attribute::Bold),
|
||||
Cell::new("Labels").add_attribute(Attribute::Bold),
|
||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
||||
]);
|
||||
headers.extend(["Assignee", "Labels", "Disc", "Updated"]);
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(header);
|
||||
let mut table = LoreTable::new().headers(&headers).align(0, Align::Right);
|
||||
|
||||
for issue in &result.issues {
|
||||
let title = truncate_with_ellipsis(&issue.title, 45);
|
||||
let relative_time = format_relative_time(issue.updated_at);
|
||||
let labels = format_labels(&issue.labels, 2);
|
||||
let title = render::truncate(&issue.title, 45);
|
||||
let relative_time = render::format_relative_time_compact(issue.updated_at);
|
||||
let labels = render::format_labels_bare(&issue.labels, 2);
|
||||
let assignee = format_assignees(&issue.assignees);
|
||||
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
||||
|
||||
let state_cell = if issue.state == "opened" {
|
||||
colored_cell(&issue.state, Color::Green)
|
||||
let (icon, state_style) = if issue.state == "opened" {
|
||||
(Icons::issue_opened(), Theme::success())
|
||||
} else {
|
||||
colored_cell(&issue.state, Color::DarkGrey)
|
||||
(Icons::issue_closed(), Theme::dim())
|
||||
};
|
||||
let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style);
|
||||
|
||||
let mut row = vec![
|
||||
colored_cell(format!("#{}", issue.iid), Color::Cyan),
|
||||
Cell::new(title),
|
||||
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
|
||||
StyledCell::plain(title),
|
||||
state_cell,
|
||||
];
|
||||
if has_any_status {
|
||||
match &issue.status_name {
|
||||
Some(status) => {
|
||||
row.push(colored_cell_hex(status, issue.status_color.as_deref()));
|
||||
row.push(StyledCell::plain(render::style_with_hex(
|
||||
status,
|
||||
issue.status_color.as_deref(),
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
row.push(Cell::new(""));
|
||||
row.push(StyledCell::plain(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
row.extend([
|
||||
colored_cell(assignee, Color::Magenta),
|
||||
colored_cell(labels, Color::Yellow),
|
||||
Cell::new(discussions),
|
||||
colored_cell(relative_time, Color::DarkGrey),
|
||||
StyledCell::styled(assignee, Theme::accent()),
|
||||
StyledCell::styled(labels, Theme::warning()),
|
||||
discussions,
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
table.add_row(row);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
@@ -877,58 +787,53 @@ pub fn print_list_mrs(result: &MrListResult) {
|
||||
}
|
||||
|
||||
println!(
|
||||
"Merge Requests (showing {} of {})\n",
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Merge Requests"),
|
||||
result.mrs.len(),
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
||||
Cell::new("State").add_attribute(Attribute::Bold),
|
||||
Cell::new("Author").add_attribute(Attribute::Bold),
|
||||
Cell::new("Branches").add_attribute(Attribute::Bold),
|
||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
||||
]);
|
||||
let mut table = LoreTable::new()
|
||||
.headers(&[
|
||||
"IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
|
||||
])
|
||||
.align(0, Align::Right);
|
||||
|
||||
for mr in &result.mrs {
|
||||
let title = if mr.draft {
|
||||
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
|
||||
format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42))
|
||||
} else {
|
||||
truncate_with_ellipsis(&mr.title, 45)
|
||||
render::truncate(&mr.title, 45)
|
||||
};
|
||||
|
||||
let relative_time = format_relative_time(mr.updated_at);
|
||||
let relative_time = render::format_relative_time_compact(mr.updated_at);
|
||||
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
|
||||
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
||||
|
||||
let state_cell = match mr.state.as_str() {
|
||||
"opened" => colored_cell(&mr.state, Color::Green),
|
||||
"merged" => colored_cell(&mr.state, Color::Magenta),
|
||||
"closed" => colored_cell(&mr.state, Color::Red),
|
||||
"locked" => colored_cell(&mr.state, Color::Yellow),
|
||||
_ => colored_cell(&mr.state, Color::DarkGrey),
|
||||
let (icon, style) = match mr.state.as_str() {
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||
"locked" => (Icons::mr_opened(), Theme::warning()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style);
|
||||
|
||||
table.add_row(vec![
|
||||
colored_cell(format!("!{}", mr.iid), Color::Cyan),
|
||||
Cell::new(title),
|
||||
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
|
||||
StyledCell::plain(title),
|
||||
state_cell,
|
||||
colored_cell(
|
||||
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
|
||||
Color::Magenta,
|
||||
StyledCell::styled(
|
||||
format!("@{}", render::truncate(&mr.author_username, 12)),
|
||||
Theme::accent(),
|
||||
),
|
||||
colored_cell(branches, Color::Blue),
|
||||
Cell::new(discussions),
|
||||
colored_cell(relative_time, Color::DarkGrey),
|
||||
StyledCell::styled(branches, Theme::info()),
|
||||
discussions,
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
@@ -966,77 +871,513 @@ pub fn open_mr_in_browser(result: &MrListResult) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn truncate_leaves_short_strings_alone() {
|
||||
assert_eq!(truncate_with_ellipsis("short", 10), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_adds_ellipsis_to_long_strings() {
|
||||
assert_eq!(
|
||||
truncate_with_ellipsis("this is a very long title", 15),
|
||||
"this is a ve..."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handles_exact_length() {
|
||||
assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_time_formats_correctly() {
|
||||
let now = now_ms();
|
||||
|
||||
assert_eq!(format_relative_time(now - 30_000), "just now");
|
||||
assert_eq!(format_relative_time(now - 120_000), "2 min ago");
|
||||
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago");
|
||||
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_empty() {
|
||||
assert_eq!(format_labels(&[], 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_single() {
|
||||
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_multiple() {
|
||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_overflow() {
|
||||
let labels = vec![
|
||||
"bug".to_string(),
|
||||
"urgent".to_string(),
|
||||
"wip".to_string(),
|
||||
"blocked".to_string(),
|
||||
];
|
||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_empty() {
|
||||
assert_eq!(format_discussions(0, 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_no_unresolved() {
|
||||
assert_eq!(format_discussions(5, 0), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_with_unresolved() {
|
||||
assert_eq!(format_discussions(5, 2), "5/2!");
|
||||
fn truncate_body(body: &str, max_len: usize) -> String {
|
||||
if body.chars().count() <= max_len {
|
||||
body.to_string()
|
||||
} else {
|
||||
let truncated: String = body.chars().take(max_len).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_note_type(note_type: Option<&str>) -> &str {
|
||||
match note_type {
|
||||
Some("DiffNote") => "Diff",
|
||||
Some("DiscussionNote") => "Disc",
|
||||
_ => "-",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_note_path(path: Option<&str>, line: Option<i64>) -> String {
|
||||
match (path, line) {
|
||||
(Some(p), Some(l)) => format!("{p}:{l}"),
|
||||
(Some(p), None) => p.to_string(),
|
||||
_ => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option<i64>) -> String {
|
||||
match (noteable_type, parent_iid) {
|
||||
(Some("Issue"), Some(iid)) => format!("Issue #{iid}"),
|
||||
(Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"),
|
||||
_ => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_list_notes(result: &NoteListResult) {
|
||||
if result.notes.is_empty() {
|
||||
println!("No notes found.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Notes"),
|
||||
result.notes.len(),
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let mut table = LoreTable::new()
|
||||
.headers(&[
|
||||
"ID",
|
||||
"Author",
|
||||
"Type",
|
||||
"Body",
|
||||
"Path:Line",
|
||||
"Parent",
|
||||
"Created",
|
||||
])
|
||||
.align(0, Align::Right);
|
||||
|
||||
for note in &result.notes {
|
||||
let body = note
|
||||
.body
|
||||
.as_deref()
|
||||
.map(|b| truncate_body(b, 60))
|
||||
.unwrap_or_default();
|
||||
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
|
||||
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
|
||||
let relative_time = render::format_relative_time_compact(note.created_at);
|
||||
let note_type = format_note_type(note.note_type.as_deref());
|
||||
|
||||
table.add_row(vec![
|
||||
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
|
||||
StyledCell::styled(
|
||||
format!("@{}", render::truncate(¬e.author_username, 12)),
|
||||
Theme::accent(),
|
||||
),
|
||||
StyledCell::plain(note_type),
|
||||
StyledCell::plain(body),
|
||||
StyledCell::plain(path),
|
||||
StyledCell::plain(parent),
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
let json_result = NoteListResultJson::from(result);
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": json_result,
|
||||
"meta": meta,
|
||||
});
|
||||
let mut output = output;
|
||||
if let Some(f) = fields {
|
||||
let expanded = expand_fields_preset(f, "notes");
|
||||
filter_fields(&mut output, "notes", &expanded);
|
||||
}
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note query layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteListRow {
|
||||
pub id: i64,
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
pub body: Option<String>,
|
||||
pub note_type: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub position_new_path: Option<String>,
|
||||
pub position_new_line: Option<i64>,
|
||||
pub position_old_path: Option<String>,
|
||||
pub position_old_line: Option<i64>,
|
||||
pub resolvable: bool,
|
||||
pub resolved: bool,
|
||||
pub resolved_by: Option<String>,
|
||||
pub noteable_type: Option<String>,
|
||||
pub parent_iid: Option<i64>,
|
||||
pub parent_title: Option<String>,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteListRowJson {
|
||||
pub id: i64,
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note_type: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at_iso: String,
|
||||
pub updated_at_iso: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_new_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_new_line: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_old_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_old_line: Option<i64>,
|
||||
pub resolvable: bool,
|
||||
pub resolved: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub noteable_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_iid: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_title: Option<String>,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
impl From<&NoteListRow> for NoteListRowJson {
|
||||
fn from(row: &NoteListRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
gitlab_id: row.gitlab_id,
|
||||
author_username: row.author_username.clone(),
|
||||
body: row.body.clone(),
|
||||
note_type: row.note_type.clone(),
|
||||
is_system: row.is_system,
|
||||
created_at_iso: ms_to_iso(row.created_at),
|
||||
updated_at_iso: ms_to_iso(row.updated_at),
|
||||
position_new_path: row.position_new_path.clone(),
|
||||
position_new_line: row.position_new_line,
|
||||
position_old_path: row.position_old_path.clone(),
|
||||
position_old_line: row.position_old_line,
|
||||
resolvable: row.resolvable,
|
||||
resolved: row.resolved,
|
||||
resolved_by: row.resolved_by.clone(),
|
||||
noteable_type: row.noteable_type.clone(),
|
||||
parent_iid: row.parent_iid,
|
||||
parent_title: row.parent_title.clone(),
|
||||
project_path: row.project_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NoteListResult {
|
||||
pub notes: Vec<NoteListRow>,
|
||||
pub total_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteListResultJson {
|
||||
pub notes: Vec<NoteListRowJson>,
|
||||
pub total_count: i64,
|
||||
pub showing: usize,
|
||||
}
|
||||
|
||||
impl From<&NoteListResult> for NoteListResultJson {
|
||||
fn from(result: &NoteListResult) -> Self {
|
||||
Self {
|
||||
notes: result.notes.iter().map(NoteListRowJson::from).collect(),
|
||||
total_count: result.total_count,
|
||||
showing: result.notes.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoteListFilters {
|
||||
pub limit: usize,
|
||||
pub project: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub note_type: Option<String>,
|
||||
pub include_system: bool,
|
||||
pub for_issue_iid: Option<i64>,
|
||||
pub for_mr_iid: Option<i64>,
|
||||
pub note_id: Option<i64>,
|
||||
pub gitlab_note_id: Option<i64>,
|
||||
pub discussion_id: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub contains: Option<String>,
|
||||
pub resolution: Option<String>,
|
||||
pub sort: String,
|
||||
pub order: String,
|
||||
}
|
||||
|
||||
pub fn query_notes(
|
||||
conn: &Connection,
|
||||
filters: &NoteListFilters,
|
||||
config: &Config,
|
||||
) -> Result<NoteListResult> {
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
// Project filter
|
||||
if let Some(ref project) = filters.project {
|
||||
let project_id = resolve_project(conn, project)?;
|
||||
where_clauses.push("n.project_id = ?".to_string());
|
||||
params.push(Box::new(project_id));
|
||||
}
|
||||
|
||||
// Author filter (case-insensitive, strip leading @)
|
||||
if let Some(ref author) = filters.author {
|
||||
let username = author.strip_prefix('@').unwrap_or(author);
|
||||
where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string());
|
||||
params.push(Box::new(username.to_string()));
|
||||
}
|
||||
|
||||
// Note type filter
|
||||
if let Some(ref note_type) = filters.note_type {
|
||||
where_clauses.push("n.note_type = ?".to_string());
|
||||
params.push(Box::new(note_type.clone()));
|
||||
}
|
||||
|
||||
// System note filter (default: exclude system notes)
|
||||
if !filters.include_system {
|
||||
where_clauses.push("n.is_system = 0".to_string());
|
||||
}
|
||||
|
||||
// Since filter
|
||||
let since_ms = if let Some(ref since_str) = filters.since {
|
||||
let ms = parse_since(since_str).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
since_str
|
||||
))
|
||||
})?;
|
||||
where_clauses.push("n.created_at >= ?".to_string());
|
||||
params.push(Box::new(ms));
|
||||
Some(ms)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Until filter (end of day for date-only input)
|
||||
if let Some(ref until_str) = filters.until {
|
||||
let until_ms = if until_str.len() == 10
|
||||
&& until_str.chars().filter(|&c| c == '-').count() == 2
|
||||
{
|
||||
// Date-only: use end of day 23:59:59.999
|
||||
let iso_full = format!("{until_str}T23:59:59.999Z");
|
||||
crate::core::time::iso_to_ms(&iso_full).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --until value '{}'. Use YYYY-MM-DD or relative format.",
|
||||
until_str
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
parse_since(until_str).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
until_str
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
// Validate since <= until
|
||||
if let Some(s) = since_ms
|
||||
&& s > until_ms
|
||||
{
|
||||
return Err(LoreError::Other(
|
||||
"Invalid time window: --since is after --until.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
where_clauses.push("n.created_at <= ?".to_string());
|
||||
params.push(Box::new(until_ms));
|
||||
}
|
||||
|
||||
// Path filter (trailing / = prefix match, else exact)
|
||||
if let Some(ref path) = filters.path {
|
||||
if let Some(prefix) = path.strip_suffix('/') {
|
||||
let escaped = note_escape_like(prefix);
|
||||
where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string());
|
||||
params.push(Box::new(format!("{escaped}%")));
|
||||
} else {
|
||||
where_clauses.push("n.position_new_path = ?".to_string());
|
||||
params.push(Box::new(path.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Contains filter (LIKE %term% on body, case-insensitive)
|
||||
if let Some(ref contains) = filters.contains {
|
||||
let escaped = note_escape_like(contains);
|
||||
where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string());
|
||||
params.push(Box::new(format!("%{escaped}%")));
|
||||
}
|
||||
|
||||
// Resolution filter
|
||||
if let Some(ref resolution) = filters.resolution {
|
||||
match resolution.as_str() {
|
||||
"unresolved" => {
|
||||
where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string());
|
||||
}
|
||||
"resolved" => {
|
||||
where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string());
|
||||
}
|
||||
other => {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.",
|
||||
other
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For-issue-iid filter (requires project context)
|
||||
if let Some(iid) = filters.for_issue_iid {
|
||||
let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| {
|
||||
LoreError::Other(
|
||||
"Cannot filter by issue IID without a project context. Use --project or set defaultProject in config."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let project_id = resolve_project(conn, project_str)?;
|
||||
where_clauses.push(
|
||||
"d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(),
|
||||
);
|
||||
params.push(Box::new(project_id));
|
||||
params.push(Box::new(iid));
|
||||
}
|
||||
|
||||
// For-mr-iid filter (requires project context)
|
||||
if let Some(iid) = filters.for_mr_iid {
|
||||
let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| {
|
||||
LoreError::Other(
|
||||
"Cannot filter by MR IID without a project context. Use --project or set defaultProject in config."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let project_id = resolve_project(conn, project_str)?;
|
||||
where_clauses.push(
|
||||
"d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)"
|
||||
.to_string(),
|
||||
);
|
||||
params.push(Box::new(project_id));
|
||||
params.push(Box::new(iid));
|
||||
}
|
||||
|
||||
// Note ID filter
|
||||
if let Some(id) = filters.note_id {
|
||||
where_clauses.push("n.id = ?".to_string());
|
||||
params.push(Box::new(id));
|
||||
}
|
||||
|
||||
// GitLab note ID filter
|
||||
if let Some(gitlab_id) = filters.gitlab_note_id {
|
||||
where_clauses.push("n.gitlab_id = ?".to_string());
|
||||
params.push(Box::new(gitlab_id));
|
||||
}
|
||||
|
||||
// Discussion ID filter
|
||||
if let Some(ref disc_id) = filters.discussion_id {
|
||||
where_clauses.push("d.gitlab_discussion_id = ?".to_string());
|
||||
params.push(Box::new(disc_id.clone()));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// Count query
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.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_sql}"
|
||||
);
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
||||
|
||||
// Sort + order
|
||||
let sort_column = match filters.sort.as_str() {
|
||||
"updated" => "n.updated_at",
|
||||
_ => "n.created_at",
|
||||
};
|
||||
let order = if filters.order == "asc" {
|
||||
"ASC"
|
||||
} else {
|
||||
"DESC"
|
||||
};
|
||||
|
||||
let query_sql = format!(
|
||||
"SELECT
|
||||
n.id,
|
||||
n.gitlab_id,
|
||||
n.author_username,
|
||||
n.body,
|
||||
n.note_type,
|
||||
n.is_system,
|
||||
n.created_at,
|
||||
n.updated_at,
|
||||
n.position_new_path,
|
||||
n.position_new_line,
|
||||
n.position_old_path,
|
||||
n.position_old_line,
|
||||
n.resolvable,
|
||||
n.resolved,
|
||||
n.resolved_by,
|
||||
d.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS parent_iid,
|
||||
COALESCE(i.title, m.title) AS parent_title,
|
||||
p.path_with_namespace AS project_path
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.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_sql}
|
||||
ORDER BY {sort_column} {order}, n.id {order}
|
||||
LIMIT ?"
|
||||
);
|
||||
|
||||
params.push(Box::new(filters.limit as i64));
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&query_sql)?;
|
||||
let notes: Vec<NoteListRow> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let is_system_int: i64 = row.get(5)?;
|
||||
let resolvable_int: i64 = row.get(12)?;
|
||||
let resolved_int: i64 = row.get(13)?;
|
||||
|
||||
Ok(NoteListRow {
|
||||
id: row.get(0)?,
|
||||
gitlab_id: row.get(1)?,
|
||||
author_username: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||
body: row.get(3)?,
|
||||
note_type: row.get(4)?,
|
||||
is_system: is_system_int == 1,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
position_new_path: row.get(8)?,
|
||||
position_new_line: row.get(9)?,
|
||||
position_old_path: row.get(10)?,
|
||||
position_old_line: row.get(11)?,
|
||||
resolvable: resolvable_int == 1,
|
||||
resolved: resolved_int == 1,
|
||||
resolved_by: row.get(14)?,
|
||||
noteable_type: row.get(15)?,
|
||||
parent_iid: row.get(16)?,
|
||||
parent_title: row.get(17)?,
|
||||
project_path: row.get(18)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(NoteListResult { notes, total_count })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "list_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
1349
src/cli/commands/list_tests.rs
Normal file
1349
src/cli/commands/list_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
905
src/cli/commands/me/me_tests.rs
Normal file
905
src/cli/commands/me/me_tests.rs
Normal file
@@ -0,0 +1,905 @@
|
||||
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_status(
|
||||
conn,
|
||||
id,
|
||||
project_id,
|
||||
iid,
|
||||
author,
|
||||
"opened",
|
||||
Some("In Progress"),
|
||||
);
|
||||
}
|
||||
|
||||
fn insert_issue_with_state(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
project_id: i64,
|
||||
iid: i64,
|
||||
author: &str,
|
||||
state: &str,
|
||||
) {
|
||||
// For closed issues, don't set status_name (they won't appear in dashboard anyway)
|
||||
let status_name = if state == "opened" {
|
||||
Some("In Progress")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
insert_issue_with_status(conn, id, project_id, iid, author, state, status_name);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_issue_with_status(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
project_id: i64,
|
||||
iid: i64,
|
||||
author: &str,
|
||||
state: &str,
|
||||
status_name: Option<&str>,
|
||||
) {
|
||||
let ts = now_ms();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, status_name, author_username, created_at, updated_at, last_seen_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
rusqlite::params![
|
||||
id,
|
||||
id * 10,
|
||||
project_id,
|
||||
iid,
|
||||
format!("Issue {iid}"),
|
||||
state,
|
||||
status_name,
|
||||
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);
|
||||
// Notes no longer duplicate body into body_preview (summary carries the content)
|
||||
assert_eq!(results[0].body_preview, None);
|
||||
assert_eq!(results[0].summary, "new comment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── Since-Last-Check Mention Tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn since_last_check_detects_mention_with_trailing_comma() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_issue(&conn, 10, 1, 42, "someone");
|
||||
let disc_id = 100;
|
||||
insert_discussion(&conn, disc_id, 1, None, Some(10));
|
||||
let t = now_ms() - 1000;
|
||||
insert_note_at(
|
||||
&conn,
|
||||
200,
|
||||
disc_id,
|
||||
1,
|
||||
"bob",
|
||||
false,
|
||||
"please review this @alice, thanks",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 1, "expected mention with comma to match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_ignores_email_like_text() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_issue(&conn, 10, 1, 42, "someone");
|
||||
let disc_id = 100;
|
||||
insert_discussion(&conn, disc_id, 1, None, Some(10));
|
||||
let t = now_ms() - 1000;
|
||||
insert_note_at(
|
||||
&conn,
|
||||
200,
|
||||
disc_id,
|
||||
1,
|
||||
"bob",
|
||||
false,
|
||||
"contact alice at foo@alice.com",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 0, "email text should not count as mention");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_detects_mention_with_trailing_period() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_issue(&conn, 10, 1, 42, "someone");
|
||||
let disc_id = 100;
|
||||
insert_discussion(&conn, disc_id, 1, None, Some(10));
|
||||
let t = now_ms() - 1000;
|
||||
insert_note_at(
|
||||
&conn,
|
||||
200,
|
||||
disc_id,
|
||||
1,
|
||||
"bob",
|
||||
false,
|
||||
"please review this @alice.",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 1, "expected mention with period to match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_detects_mention_inside_parentheses() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_issue(&conn, 10, 1, 42, "someone");
|
||||
let disc_id = 100;
|
||||
insert_discussion(&conn, disc_id, 1, None, Some(10));
|
||||
let t = now_ms() - 1000;
|
||||
insert_note_at(
|
||||
&conn,
|
||||
200,
|
||||
disc_id,
|
||||
1,
|
||||
"bob",
|
||||
false,
|
||||
"thanks (@alice) for the update",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 1, "expected parenthesized mention to match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_ignores_domain_like_text() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_issue(&conn, 10, 1, 42, "someone");
|
||||
let disc_id = 100;
|
||||
insert_discussion(&conn, disc_id, 1, None, Some(10));
|
||||
let t = now_ms() - 1000;
|
||||
insert_note_at(
|
||||
&conn,
|
||||
200,
|
||||
disc_id,
|
||||
1,
|
||||
"bob",
|
||||
false,
|
||||
"@alice.com is the old hostname",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(
|
||||
total_events, 0,
|
||||
"domain-like text should not count as mention"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
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("mention_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)");
|
||||
}
|
||||
500
src/cli/commands/me/mod.rs
Normal file
500
src/cli/commands/me/mod.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
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::cursor;
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::parse_since;
|
||||
|
||||
use self::queries::{
|
||||
query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs,
|
||||
query_since_last_check,
|
||||
};
|
||||
use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck};
|
||||
|
||||
/// Default activity lookback: 1 day in milliseconds.
|
||||
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
||||
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();
|
||||
let username = resolve_username(args, config)?;
|
||||
|
||||
// 0. Handle --reset-cursor early return
|
||||
if args.reset_cursor {
|
||||
cursor::reset_cursor(username)
|
||||
.map_err(|e| LoreError::Other(format!("reset cursor: {e}")))?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
if robot_mode {
|
||||
render_robot::print_cursor_reset_json(elapsed_ms)?;
|
||||
} else {
|
||||
println!("Cursor reset for @{username}. Next `lore me` will establish a new baseline.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 1. Open DB
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
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 project scope
|
||||
let project_ids = resolve_project_scope(&conn, args, config)?;
|
||||
let single_project = project_ids.len() == 1;
|
||||
|
||||
// 4. Parse --since (default 1d for activity feed)
|
||||
let since_ms = match args.since.as_deref() {
|
||||
Some(raw) => parse_since(raw).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"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,
|
||||
};
|
||||
|
||||
// 5. Determine which sections to query
|
||||
let show_all = args.show_all_sections();
|
||||
let want_issues = show_all || args.issues;
|
||||
let want_mrs = show_all || args.mrs;
|
||||
let want_activity = show_all || args.activity;
|
||||
|
||||
// 6. 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()
|
||||
};
|
||||
|
||||
// 6b. Since-last-check (cursor-based inbox)
|
||||
let cursor_ms = cursor::read_cursor(username);
|
||||
// Capture global watermark BEFORE project filtering so --project doesn't
|
||||
// permanently skip events from other projects.
|
||||
let mut global_watermark: Option<i64> = None;
|
||||
let since_last_check = if let Some(prev_cursor) = cursor_ms {
|
||||
let groups = query_since_last_check(&conn, username, prev_cursor)?;
|
||||
// Watermark from ALL groups (unfiltered) — this is the true high-water mark
|
||||
global_watermark = groups.iter().map(|g| g.latest_timestamp).max();
|
||||
// If --project was passed, filter groups by project for display only
|
||||
let groups = if !project_ids.is_empty() {
|
||||
filter_groups_by_project_ids(&conn, &groups, &project_ids)
|
||||
} else {
|
||||
groups
|
||||
};
|
||||
let total = groups.iter().map(|g| g.events.len()).sum();
|
||||
Some(SinceLastCheck {
|
||||
cursor_ms: prev_cursor,
|
||||
groups,
|
||||
total_event_count: total,
|
||||
})
|
||||
} else {
|
||||
None // First run — no section shown
|
||||
};
|
||||
|
||||
// 7. Compute summary
|
||||
let needs_attention_count = open_issues
|
||||
.iter()
|
||||
.filter(|i| i.attention_state == AttentionState::NeedsAttention)
|
||||
.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,
|
||||
};
|
||||
|
||||
// 8. Assemble dashboard
|
||||
let dashboard = MeDashboard {
|
||||
username: username.to_string(),
|
||||
since_ms: Some(since_ms),
|
||||
summary,
|
||||
open_issues,
|
||||
open_mrs_authored,
|
||||
reviewing_mrs,
|
||||
activity,
|
||||
since_last_check,
|
||||
};
|
||||
|
||||
// 9. 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,
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Advance cursor AFTER successful render (watermark pattern)
|
||||
// Uses max event timestamp from UNFILTERED results so --project filtering
|
||||
// doesn't permanently skip events from other projects.
|
||||
let watermark = global_watermark.unwrap_or_else(crate::core::time::now_ms);
|
||||
cursor::write_cursor(username, watermark)
|
||||
.map_err(|e| LoreError::Other(format!("write cursor: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filter since-last-check groups to only those matching the given project IDs.
|
||||
/// Used when --project narrows the display scope (cursor is still global).
|
||||
fn filter_groups_by_project_ids(
|
||||
conn: &Connection,
|
||||
groups: &[types::SinceCheckGroup],
|
||||
project_ids: &[i64],
|
||||
) -> Vec<types::SinceCheckGroup> {
|
||||
// Resolve project IDs to paths for matching
|
||||
let paths: HashSet<String> = project_ids
|
||||
.iter()
|
||||
.filter_map(|pid| {
|
||||
conn.query_row(
|
||||
"SELECT path_with_namespace FROM projects WHERE id = ?1",
|
||||
rusqlite::params![pid],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
groups
|
||||
.iter()
|
||||
.filter(|g| paths.contains(&g.project_path))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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,
|
||||
reset_cursor: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
838
src/cli/commands/me/queries.rs
Normal file
838
src/cli/commands/me/queries.rs
Normal file
@@ -0,0 +1,838 @@
|
||||
// ─── 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 regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::types::{
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr, SinceCheckEvent,
|
||||
SinceCheckGroup,
|
||||
};
|
||||
|
||||
/// Stale threshold: items with no activity for 30 days are marked "stale".
|
||||
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
||||
|
||||
// ─── 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.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 'stale'
|
||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 'needs_attention'
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
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'
|
||||
AND (i.status_name COLLATE NOCASE IN ('In Progress', 'In Review') OR i.status_name IS NULL)
|
||||
{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)
|
||||
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
|
||||
THEN 0
|
||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
|
||||
THEN 1
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
|
||||
THEN 2
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 3
|
||||
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.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 'stale'
|
||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 'needs_attention'
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
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)
|
||||
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
|
||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
||||
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.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 'stale'
|
||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 'needs_attention'
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
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)
|
||||
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
|
||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
||||
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
|
||||
// AND currently open (AC-3.6). Without the state filter, activity would include
|
||||
// events on closed/merged items that don't appear in the dashboard lists.
|
||||
let my_issue_check = "EXISTS (
|
||||
SELECT 1 FROM issue_assignees ia
|
||||
JOIN issues i2 ON ia.issue_id = i2.id
|
||||
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
|
||||
)";
|
||||
let my_mr_check = "(
|
||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
|
||||
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
||||
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id
|
||||
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
|
||||
)";
|
||||
|
||||
// Source 1: 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),
|
||||
NULL
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.is_system = 0
|
||||
AND n.created_at >= ?2
|
||||
{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
|
||||
LIMIT 100"
|
||||
);
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ─── Since Last Check (cursor-based inbox) ──────────────────────────────────
|
||||
|
||||
/// Raw row from the since-last-check UNION query.
|
||||
struct RawSinceCheckRow {
|
||||
timestamp: i64,
|
||||
event_type: String,
|
||||
entity_type: String,
|
||||
entity_iid: i64,
|
||||
entity_title: String,
|
||||
project_path: String,
|
||||
actor: Option<String>,
|
||||
summary: String,
|
||||
body_preview: Option<String>,
|
||||
is_mention_source: bool,
|
||||
mention_body: Option<String>,
|
||||
}
|
||||
|
||||
/// Query actionable events from others since `cursor_ms`.
|
||||
/// Returns events from three sources:
|
||||
/// 1. Others' comments on my open items
|
||||
/// 2. @mentions on any item (not restricted to my items)
|
||||
/// 3. Assignment/review-request system notes mentioning me
|
||||
pub fn query_since_last_check(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
cursor_ms: i64,
|
||||
) -> Result<Vec<SinceCheckGroup>> {
|
||||
// Build the "my items" subquery fragments (reused from activity).
|
||||
let my_issue_check = "EXISTS (
|
||||
SELECT 1 FROM issue_assignees ia
|
||||
JOIN issues i2 ON ia.issue_id = i2.id
|
||||
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
|
||||
)";
|
||||
let my_mr_check = "(
|
||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
|
||||
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
||||
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id
|
||||
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
|
||||
)";
|
||||
|
||||
// Source 1: Others' comments on my open items
|
||||
let source1 = format!(
|
||||
"SELECT n.created_at, 'note',
|
||||
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
||||
COALESCE(i.iid, m.iid),
|
||||
COALESCE(i.title, m.title),
|
||||
p.path_with_namespace,
|
||||
n.author_username,
|
||||
SUBSTR(n.body, 1, 200),
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.is_system = 0
|
||||
AND n.created_at > ?2
|
||||
AND n.author_username != ?1
|
||||
AND (
|
||||
(d.issue_id IS NOT NULL AND {issue_check})
|
||||
OR (d.merge_request_id IS NOT NULL AND {mr_check})
|
||||
)",
|
||||
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
|
||||
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
|
||||
);
|
||||
|
||||
// Source 2: @mentions on ANY item (not restricted to my items)
|
||||
// Word-boundary-aware matching to reduce false positives
|
||||
let source2 = format!(
|
||||
"SELECT n.created_at, 'mention_note',
|
||||
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
||||
COALESCE(i.iid, m.iid),
|
||||
COALESCE(i.title, m.title),
|
||||
p.path_with_namespace,
|
||||
n.author_username,
|
||||
SUBSTR(n.body, 1, 200),
|
||||
NULL,
|
||||
1,
|
||||
n.body
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.is_system = 0
|
||||
AND n.created_at > ?2
|
||||
AND n.author_username != ?1
|
||||
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
|
||||
AND NOT (
|
||||
(d.issue_id IS NOT NULL AND {issue_check})
|
||||
OR (d.merge_request_id IS NOT NULL AND {mr_check})
|
||||
)",
|
||||
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
|
||||
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
|
||||
);
|
||||
|
||||
// Source 3: Assignment/review-request system notes mentioning me
|
||||
let source3 = "SELECT n.created_at,
|
||||
CASE
|
||||
WHEN LOWER(n.body) LIKE '%assigned to @%' THEN 'assign'
|
||||
WHEN LOWER(n.body) LIKE '%unassigned @%' THEN 'unassign'
|
||||
WHEN LOWER(n.body) LIKE '%requested review from @%' THEN 'review_request'
|
||||
ELSE 'assign'
|
||||
END,
|
||||
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
||||
COALESCE(i.iid, m.iid),
|
||||
COALESCE(i.title, m.title),
|
||||
p.path_with_namespace,
|
||||
n.author_username,
|
||||
n.body,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.is_system = 1
|
||||
AND n.created_at > ?2
|
||||
AND n.author_username != ?1
|
||||
AND (
|
||||
LOWER(n.body) LIKE '%assigned to @' || LOWER(?1) || '%'
|
||||
OR LOWER(n.body) LIKE '%unassigned @' || LOWER(?1) || '%'
|
||||
OR LOWER(n.body) LIKE '%requested review from @' || LOWER(?1) || '%'
|
||||
)"
|
||||
.to_string();
|
||||
|
||||
let full_sql = format!(
|
||||
"{source1}
|
||||
UNION ALL {source2}
|
||||
UNION ALL {source3}
|
||||
ORDER BY 1 DESC
|
||||
LIMIT 200"
|
||||
);
|
||||
|
||||
let params: Vec<Box<dyn rusqlite::types::ToSql>> =
|
||||
vec![Box::new(username.to_string()), Box::new(cursor_ms)];
|
||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&full_sql)?;
|
||||
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(RawSinceCheckRow {
|
||||
timestamp: row.get(0)?,
|
||||
event_type: row.get(1)?,
|
||||
entity_type: row.get(2)?,
|
||||
entity_iid: row.get(3)?,
|
||||
entity_title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
||||
project_path: row.get(5)?,
|
||||
actor: row.get(6)?,
|
||||
summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||
body_preview: row.get(8)?,
|
||||
is_mention_source: row.get::<_, i32>(9)? != 0,
|
||||
mention_body: row.get(10)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mention_re = build_exact_mention_regex(username);
|
||||
let raw_events: Vec<RawSinceCheckRow> = rows
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter(|row| {
|
||||
!row.is_mention_source
|
||||
|| row
|
||||
.mention_body
|
||||
.as_deref()
|
||||
.is_some_and(|body| contains_exact_mention(body, &mention_re))
|
||||
})
|
||||
.collect();
|
||||
Ok(group_since_check_events(raw_events))
|
||||
}
|
||||
|
||||
/// Group flat event rows by entity, sort groups newest-first, events within oldest-first.
|
||||
fn group_since_check_events(rows: Vec<RawSinceCheckRow>) -> Vec<SinceCheckGroup> {
|
||||
// Key: (entity_type, entity_iid, project_path)
|
||||
let mut groups: HashMap<(String, i64, String), SinceCheckGroup> = HashMap::new();
|
||||
|
||||
for row in rows {
|
||||
let key = (
|
||||
row.entity_type.clone(),
|
||||
row.entity_iid,
|
||||
row.project_path.clone(),
|
||||
);
|
||||
let group = groups.entry(key).or_insert_with(|| SinceCheckGroup {
|
||||
entity_type: row.entity_type.clone(),
|
||||
entity_iid: row.entity_iid,
|
||||
entity_title: row.entity_title.clone(),
|
||||
project_path: row.project_path.clone(),
|
||||
events: Vec::new(),
|
||||
latest_timestamp: 0,
|
||||
});
|
||||
|
||||
if row.timestamp > group.latest_timestamp {
|
||||
group.latest_timestamp = row.timestamp;
|
||||
}
|
||||
|
||||
group.events.push(SinceCheckEvent {
|
||||
timestamp: row.timestamp,
|
||||
event_type: parse_event_type(&row.event_type),
|
||||
actor: row.actor,
|
||||
summary: row.summary,
|
||||
body_preview: row.body_preview,
|
||||
});
|
||||
}
|
||||
|
||||
let mut result: Vec<SinceCheckGroup> = groups.into_values().collect();
|
||||
// Sort groups newest-first
|
||||
result.sort_by_key(|g| std::cmp::Reverse(g.latest_timestamp));
|
||||
// Sort events within each group oldest-first (read top-to-bottom)
|
||||
for group in &mut result {
|
||||
group.events.sort_by_key(|e| e.timestamp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse attention state string from SQL CASE result.
|
||||
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,
|
||||
"mention_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,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exact_mention_regex(username: &str) -> Regex {
|
||||
let escaped = regex::escape(username);
|
||||
let pattern = format!(r"(?i)@{escaped}");
|
||||
Regex::new(&pattern).expect("mention regex must compile")
|
||||
}
|
||||
|
||||
fn contains_exact_mention(body: &str, mention_re: &Regex) -> bool {
|
||||
for m in mention_re.find_iter(body) {
|
||||
let start = m.start();
|
||||
let end = m.end();
|
||||
|
||||
let prev = body[..start].chars().next_back();
|
||||
if prev.is_some_and(is_username_char) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(next) = body[end..].chars().next() {
|
||||
// Reject domain-like continuations such as "@alice.com"
|
||||
if next == '.' {
|
||||
let after_dot = body[end + next.len_utf8()..].chars().next();
|
||||
if after_dot.is_some_and(is_username_char) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if is_username_char(next) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_username_char(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
|
||||
}
|
||||
|
||||
/// Build a SQL clause for project ID filtering.
|
||||
/// `start_idx` is the 1-based parameter index for the first project ID.
|
||||
/// Returns empty string when no filter is needed (all projects).
|
||||
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;
|
||||
667
src/cli/commands/me/render_human.rs
Normal file
667
src/cli/commands/me/render_human.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
|
||||
|
||||
use super::types::{
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||
SinceLastCheck,
|
||||
};
|
||||
|
||||
// ─── Layout Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the title/summary column width for a section given its fixed overhead.
|
||||
/// Returns a width clamped to [20, 80].
|
||||
fn title_width(overhead: usize) -> usize {
|
||||
render::terminal_width()
|
||||
.saturating_sub(overhead)
|
||||
.clamp(20, 80)
|
||||
}
|
||||
|
||||
// ─── Glyph Mode Helper ──────────────────────────────────────────────────────
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// ─── Merge Status Labels ────────────────────────────────────────────────────
|
||||
|
||||
/// Convert GitLab's `detailed_merge_status` API values to human-friendly labels.
|
||||
fn humanize_merge_status(status: &str) -> &str {
|
||||
match status {
|
||||
"not_approved" => "needs approval",
|
||||
"requested_changes" => "changes requested",
|
||||
"mergeable" => "ready to merge",
|
||||
"not_open" => "not open",
|
||||
"checking" => "checking",
|
||||
"ci_must_pass" => "CI pending",
|
||||
"ci_still_running" => "CI running",
|
||||
"discussions_not_resolved" => "unresolved threads",
|
||||
"draft_status" => "draft",
|
||||
"need_rebase" => "needs rebase",
|
||||
"conflict" | "has_conflicts" => "has conflicts",
|
||||
"blocked_status" => "blocked",
|
||||
"approvals_syncing" => "syncing approvals",
|
||||
"jira_association_missing" => "missing Jira link",
|
||||
"unchecked" => "unchecked",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event Badges ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the badge label text for an activity event type.
|
||||
fn activity_badge_label(event_type: &ActivityEventType) -> String {
|
||||
match event_type {
|
||||
ActivityEventType::Note => "note",
|
||||
ActivityEventType::StatusChange => "status",
|
||||
ActivityEventType::LabelChange => "label",
|
||||
ActivityEventType::Assign | ActivityEventType::Unassign => "assign",
|
||||
ActivityEventType::ReviewRequest => "review",
|
||||
ActivityEventType::MilestoneChange => "milestone",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Return the style for an activity event badge.
|
||||
fn activity_badge_style(event_type: &ActivityEventType) -> lipgloss::Style {
|
||||
match event_type {
|
||||
ActivityEventType::Note => Theme::info(),
|
||||
ActivityEventType::StatusChange => Theme::warning(),
|
||||
ActivityEventType::LabelChange => Theme::accent(),
|
||||
ActivityEventType::Assign
|
||||
| ActivityEventType::Unassign
|
||||
| ActivityEventType::ReviewRequest => Theme::success(),
|
||||
ActivityEventType::MilestoneChange => accent_magenta(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Very dark gray for system events (label, assign, status, milestone, review).
|
||||
fn system_event_style() -> lipgloss::Style {
|
||||
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
|
||||
lipgloss::Style::new().foreground("#555555")
|
||||
} else {
|
||||
lipgloss::Style::new().faint()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Summary Header ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 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(render::terminal_width()));
|
||||
|
||||
// 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, title_width(43)),
|
||||
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!(" ({})", humanize_merge_status(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, title_width(48)),
|
||||
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, title_width(50)),
|
||||
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()))
|
||||
);
|
||||
|
||||
// Columns: badge | ref | summary | actor | time
|
||||
// Table handles alignment, padding, and truncation automatically.
|
||||
let summary_max = title_width(46);
|
||||
let mut table = Table::new()
|
||||
.columns(5)
|
||||
.indent(4)
|
||||
.align(1, Align::Right)
|
||||
.align(4, Align::Right)
|
||||
.max_width(2, summary_max);
|
||||
|
||||
for event in events {
|
||||
let badge_label = activity_badge_label(&event.event_type);
|
||||
let badge_style = activity_badge_style(&event.event_type);
|
||||
|
||||
let ref_text = match event.entity_type.as_str() {
|
||||
"issue" => format!("#{}", event.entity_iid),
|
||||
"mr" => format!("!{}", event.entity_iid),
|
||||
_ => format!("{}:{}", event.entity_type, event.entity_iid),
|
||||
};
|
||||
let is_system = !matches!(event.event_type, ActivityEventType::Note);
|
||||
// System events → very dark gray; own notes → standard dim; else → full color.
|
||||
let subdued = is_system || event.is_own;
|
||||
let subdued_style = || {
|
||||
if is_system {
|
||||
system_event_style()
|
||||
} else {
|
||||
Theme::dim()
|
||||
}
|
||||
};
|
||||
|
||||
let badge_style_final = if subdued {
|
||||
subdued_style()
|
||||
} else {
|
||||
badge_style
|
||||
};
|
||||
|
||||
let ref_style = if subdued {
|
||||
Some(subdued_style())
|
||||
} else {
|
||||
match event.entity_type.as_str() {
|
||||
"issue" => Some(Theme::issue_ref()),
|
||||
"mr" => Some(Theme::mr_ref()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let clean_summary = event.summary.replace('\n', " ");
|
||||
let summary_style: Option<lipgloss::Style> =
|
||||
if subdued { Some(subdued_style()) } else { None };
|
||||
|
||||
let actor_text = if event.is_own {
|
||||
event
|
||||
.actor
|
||||
.as_deref()
|
||||
.map_or("(you)".to_string(), |a| format!("@{a} (you)"))
|
||||
} else {
|
||||
event
|
||||
.actor
|
||||
.as_deref()
|
||||
.map_or(String::new(), |a| format!("@{a}"))
|
||||
};
|
||||
let actor_style = if subdued {
|
||||
subdued_style()
|
||||
} else {
|
||||
Theme::username()
|
||||
};
|
||||
|
||||
let time = render::format_relative_time_compact(event.timestamp);
|
||||
|
||||
table.add_row(vec![
|
||||
StyledCell::styled(badge_label, badge_style_final),
|
||||
match ref_style {
|
||||
Some(s) => StyledCell::styled(ref_text, s),
|
||||
None => StyledCell::plain(ref_text),
|
||||
},
|
||||
match summary_style {
|
||||
Some(s) => StyledCell::styled(clean_summary, s),
|
||||
None => StyledCell::plain(clean_summary),
|
||||
},
|
||||
StyledCell::styled(actor_text, actor_style),
|
||||
StyledCell::styled(time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
// Render table rows and interleave per-event detail lines
|
||||
let rendered = table.render();
|
||||
for (line, event) in rendered.lines().zip(events.iter()) {
|
||||
println!("{line}");
|
||||
if !single_project {
|
||||
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), right-aligned to 6 chars.
|
||||
#[cfg(test)]
|
||||
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||
match entity_type {
|
||||
"issue" => {
|
||||
let s = format!("{:>6}", format!("#{iid}"));
|
||||
Theme::issue_ref().render(&s)
|
||||
}
|
||||
"mr" => {
|
||||
let s = format!("{:>6}", format!("!{iid}"));
|
||||
Theme::mr_ref().render(&s)
|
||||
}
|
||||
_ => format!("{:>6}", format!("{entity_type}:{iid}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Since Last Check ────────────────────────────────────────────────────────
|
||||
|
||||
/// Print the "since last check" section at the top of the dashboard.
|
||||
pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bool) {
|
||||
let relative = render::format_relative_time(since.cursor_ms);
|
||||
|
||||
if since.groups.is_empty() {
|
||||
println!(
|
||||
"\n {}",
|
||||
Theme::dim().render(&format!(
|
||||
"No new events since {} ({relative})",
|
||||
render::format_datetime(since.cursor_ms),
|
||||
))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
render::section_divider(&format!("Since Last Check ({relative})"))
|
||||
);
|
||||
|
||||
for group in &since.groups {
|
||||
// Entity header: !247 Fix race condition...
|
||||
let ref_str = match group.entity_type.as_str() {
|
||||
"issue" => format!("#{}", group.entity_iid),
|
||||
"mr" => format!("!{}", group.entity_iid),
|
||||
_ => format!("{}:{}", group.entity_type, group.entity_iid),
|
||||
};
|
||||
let ref_style = match group.entity_type.as_str() {
|
||||
"issue" => Theme::issue_ref(),
|
||||
"mr" => Theme::mr_ref(),
|
||||
_ => Theme::bold(),
|
||||
};
|
||||
|
||||
println!();
|
||||
println!(
|
||||
" {} {}",
|
||||
ref_style.render(&ref_str),
|
||||
Theme::bold().render(&render::truncate(&group.entity_title, title_width(20))),
|
||||
);
|
||||
if !single_project {
|
||||
println!(" {}", Theme::dim().render(&group.project_path));
|
||||
}
|
||||
|
||||
// Sub-events as indented rows
|
||||
let summary_max = title_width(42);
|
||||
let mut table = Table::new()
|
||||
.columns(3)
|
||||
.indent(6)
|
||||
.align(2, Align::Right)
|
||||
.max_width(1, summary_max);
|
||||
|
||||
for event in &group.events {
|
||||
let badge = activity_badge_label(&event.event_type);
|
||||
let badge_style = activity_badge_style(&event.event_type);
|
||||
|
||||
let actor_prefix = event
|
||||
.actor
|
||||
.as_deref()
|
||||
.map(|a| format!("@{a} "))
|
||||
.unwrap_or_default();
|
||||
let clean_summary = event.summary.replace('\n', " ");
|
||||
let summary_text = format!("{actor_prefix}{clean_summary}");
|
||||
|
||||
let time = render::format_relative_time_compact(event.timestamp);
|
||||
|
||||
table.add_row(vec![
|
||||
StyledCell::styled(badge, badge_style),
|
||||
StyledCell::plain(summary_text),
|
||||
StyledCell::styled(time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
let rendered = table.render();
|
||||
for (line, event) in rendered.lines().zip(group.events.iter()) {
|
||||
println!("{line}");
|
||||
if let Some(preview) = &event.body_preview
|
||||
&& !preview.is_empty()
|
||||
{
|
||||
let truncated = render::truncate(preview, 60);
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("\"{truncated}\""))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
println!(
|
||||
"\n {}",
|
||||
Theme::dim().render(&format!(
|
||||
"{} events across {} items",
|
||||
since.total_event_count,
|
||||
since.groups.len()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Full Dashboard ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Render the complete human-mode dashboard.
|
||||
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
|
||||
if let Some(ref since) = dashboard.since_last_check {
|
||||
print_since_last_check_section(since, single_project);
|
||||
}
|
||||
print_summary_header(&dashboard.summary, &dashboard.username);
|
||||
print_issues_section(&dashboard.open_issues, single_project);
|
||||
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
|
||||
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,
|
||||
) {
|
||||
if let Some(ref since) = dashboard.since_last_check {
|
||||
print_since_last_check_section(since, single_project);
|
||||
}
|
||||
print_summary_header(&dashboard.summary, &dashboard.username);
|
||||
|
||||
if show_issues {
|
||||
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 activity_badge_label_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!(!activity_badge_label(t).is_empty(), "empty for {t:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
428
src/cli/commands/me/render_robot.rs
Normal file
428
src/cli/commands/me/render_robot.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
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,
|
||||
SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
|
||||
};
|
||||
|
||||
// ─── 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(())
|
||||
}
|
||||
|
||||
/// Print `--reset-cursor` response using standard robot envelope.
|
||||
pub fn print_cursor_reset_json(elapsed_ms: u64) -> crate::core::error::Result<()> {
|
||||
let value = cursor_reset_envelope_json(elapsed_ms);
|
||||
let json = serde_json::to_string(&value)
|
||||
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_reset_envelope_json(elapsed_ms: u64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"cursor_reset": true
|
||||
},
|
||||
"meta": {
|
||||
"elapsed_ms": elapsed_ms
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── JSON Envelope ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeJsonEnvelope {
|
||||
ok: bool,
|
||||
data: MeDataJson,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeDataJson {
|
||||
username: String,
|
||||
since_iso: Option<String>,
|
||||
summary: SummaryJson,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
since_last_check: Option<SinceLastCheckJson>,
|
||||
open_issues: Vec<IssueJson>,
|
||||
open_mrs_authored: Vec<MrJson>,
|
||||
reviewing_mrs: Vec<MrJson>,
|
||||
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),
|
||||
since_last_check: d.since_last_check.as_ref().map(SinceLastCheckJson::from),
|
||||
open_issues: d.open_issues.iter().map(IssueJson::from).collect(),
|
||||
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
|
||||
reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Since Last Check ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SinceLastCheckJson {
|
||||
cursor_iso: String,
|
||||
total_event_count: usize,
|
||||
groups: Vec<SinceCheckGroupJson>,
|
||||
}
|
||||
|
||||
impl From<&SinceLastCheck> for SinceLastCheckJson {
|
||||
fn from(s: &SinceLastCheck) -> Self {
|
||||
Self {
|
||||
cursor_iso: ms_to_iso(s.cursor_ms),
|
||||
total_event_count: s.total_event_count,
|
||||
groups: s.groups.iter().map(SinceCheckGroupJson::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SinceCheckGroupJson {
|
||||
entity_type: String,
|
||||
entity_iid: i64,
|
||||
entity_title: String,
|
||||
project: String,
|
||||
events: Vec<SinceCheckEventJson>,
|
||||
}
|
||||
|
||||
impl From<&SinceCheckGroup> for SinceCheckGroupJson {
|
||||
fn from(g: &SinceCheckGroup) -> Self {
|
||||
Self {
|
||||
entity_type: g.entity_type.clone(),
|
||||
entity_iid: g.entity_iid,
|
||||
entity_title: g.entity_title.clone(),
|
||||
project: g.project_path.clone(),
|
||||
events: g.events.iter().map(SinceCheckEventJson::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SinceCheckEventJson {
|
||||
timestamp_iso: String,
|
||||
event_type: String,
|
||||
actor: Option<String>,
|
||||
summary: String,
|
||||
body_preview: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&SinceCheckEvent> for SinceCheckEventJson {
|
||||
fn from(e: &SinceCheckEvent) -> Self {
|
||||
Self {
|
||||
timestamp_iso: ms_to_iso(e.timestamp),
|
||||
event_type: event_type_str(&e.event_type),
|
||||
actor: e.actor.clone(),
|
||||
summary: e.summary.clone(),
|
||||
body_preview: e.body_preview.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert `AttentionState` to its programmatic string representation.
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_reset_envelope_includes_meta_elapsed_ms() {
|
||||
let value = cursor_reset_envelope_json(17);
|
||||
assert_eq!(value["ok"], serde_json::json!(true));
|
||||
assert_eq!(value["data"]["cursor_reset"], serde_json::json!(true));
|
||||
assert_eq!(value["meta"]["elapsed_ms"], serde_json::json!(17));
|
||||
}
|
||||
}
|
||||
127
src/cli/commands/me/types.rs
Normal file
127
src/cli/commands/me/types.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
// ─── 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>,
|
||||
}
|
||||
|
||||
/// A single actionable event in the "since last check" section.
|
||||
#[derive(Clone)]
|
||||
pub struct SinceCheckEvent {
|
||||
pub timestamp: i64,
|
||||
pub event_type: ActivityEventType,
|
||||
pub actor: Option<String>,
|
||||
pub summary: String,
|
||||
pub body_preview: Option<String>,
|
||||
}
|
||||
|
||||
/// Events grouped by entity for the "since last check" section.
|
||||
#[derive(Clone)]
|
||||
pub struct SinceCheckGroup {
|
||||
pub entity_type: String,
|
||||
pub entity_iid: i64,
|
||||
pub entity_title: String,
|
||||
pub project_path: String,
|
||||
pub events: Vec<SinceCheckEvent>,
|
||||
pub latest_timestamp: i64,
|
||||
}
|
||||
|
||||
/// The complete "since last check" result.
|
||||
pub struct SinceLastCheck {
|
||||
pub cursor_ms: i64,
|
||||
pub groups: Vec<SinceCheckGroup>,
|
||||
pub total_event_count: usize,
|
||||
}
|
||||
|
||||
/// The complete dashboard result.
|
||||
pub struct MeDashboard {
|
||||
pub username: String,
|
||||
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>,
|
||||
pub since_last_check: Option<SinceLastCheck>,
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
pub mod auth_test;
|
||||
pub mod count;
|
||||
#[cfg(unix)]
|
||||
pub mod cron;
|
||||
pub mod doctor;
|
||||
pub mod drift;
|
||||
pub mod embed;
|
||||
pub mod file_history;
|
||||
pub mod generate_docs;
|
||||
pub mod ingest;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
pub mod me;
|
||||
pub mod related;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod stats;
|
||||
pub mod sync;
|
||||
pub mod sync_status;
|
||||
pub mod sync_surgical;
|
||||
pub mod timeline;
|
||||
pub mod trace;
|
||||
pub mod who;
|
||||
|
||||
pub use auth_test::run_auth_test;
|
||||
@@ -20,19 +27,29 @@ pub use count::{
|
||||
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
|
||||
run_count_events,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
pub use cron::{
|
||||
print_cron_install, print_cron_install_json, print_cron_status, print_cron_status_json,
|
||||
print_cron_uninstall, print_cron_uninstall_json, run_cron_install, run_cron_status,
|
||||
run_cron_uninstall,
|
||||
};
|
||||
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
||||
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
||||
pub use embed::{print_embed, print_embed_json, run_embed};
|
||||
pub use file_history::{print_file_history, print_file_history_json, run_file_history};
|
||||
pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs};
|
||||
pub use ingest::{
|
||||
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
|
||||
print_ingest_summary, print_ingest_summary_json, run_ingest, run_ingest_dry_run,
|
||||
};
|
||||
pub use init::{InitInputs, InitOptions, InitResult, run_init};
|
||||
pub use init::{InitInputs, InitOptions, InitResult, run_init, run_token_set, run_token_show};
|
||||
pub use list::{
|
||||
ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues,
|
||||
print_list_issues_json, print_list_mrs, print_list_mrs_json, run_list_issues, run_list_mrs,
|
||||
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
|
||||
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 related::{RelatedResponse, print_related_human, print_related_json, run_related};
|
||||
pub use search::{
|
||||
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
|
||||
};
|
||||
@@ -43,5 +60,7 @@ pub use show::{
|
||||
pub use stats::{print_stats, print_stats_json, run_stats};
|
||||
pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync};
|
||||
pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status};
|
||||
pub use sync_surgical::run_sync_surgical;
|
||||
pub use timeline::{TimelineParams, print_timeline, print_timeline_json_with_meta, run_timeline};
|
||||
pub use trace::{parse_trace_path, print_trace, print_trace_json};
|
||||
pub use who::{WhoRun, print_who_human, print_who_json, run_who};
|
||||
|
||||
637
src/cli/commands/related.rs
Normal file
637
src/cli/commands/related.rs
Normal file
@@ -0,0 +1,637 @@
|
||||
//! Semantic similarity discovery: find related entities via vector search.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::config::Config;
|
||||
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::ms_to_iso;
|
||||
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||
use crate::search::search_vector;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RelatedResponse {
|
||||
pub mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<RelatedSource>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub query: Option<String>,
|
||||
pub results: Vec<RelatedResult>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RelatedSource {
|
||||
pub source_type: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RelatedResult {
|
||||
pub source_type: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub similarity_score: f64,
|
||||
pub project_path: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub shared_labels: Vec<String>,
|
||||
pub author: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal row types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct DocumentRow {
|
||||
id: i64,
|
||||
source_type: String,
|
||||
source_id: i64,
|
||||
#[allow(dead_code)]
|
||||
project_id: i64,
|
||||
#[allow(dead_code)]
|
||||
title: Option<String>,
|
||||
url: Option<String>,
|
||||
content_text: String,
|
||||
label_names: Option<String>,
|
||||
author_username: Option<String>,
|
||||
updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
struct EntityInfo {
|
||||
#[allow(dead_code)]
|
||||
iid: i64,
|
||||
title: String,
|
||||
project_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the related command.
|
||||
///
|
||||
/// Modes:
|
||||
/// - Entity mode: `lore related issues 42` or `lore related mrs 99`
|
||||
/// - Query mode: `lore related 'search terms'`
|
||||
pub async fn run_related(
|
||||
config: &Config,
|
||||
query_or_type: &str,
|
||||
iid: Option<i64>,
|
||||
limit: usize,
|
||||
project: Option<&str>,
|
||||
) -> Result<RelatedResponse> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
// Check if embeddings exist
|
||||
let embedding_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM embedding_metadata", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if embedding_count == 0 {
|
||||
return Err(LoreError::Other(
|
||||
"No embeddings found. Run 'lore embed' first to generate vector embeddings.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if query_or_type.trim().is_empty() {
|
||||
return Err(LoreError::Other(
|
||||
"Query cannot be empty. Provide an entity type (issues/mrs) and IID, or a search query.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Determine mode: entity vs query
|
||||
let entity_type = match query_or_type.to_lowercase().as_str() {
|
||||
"issues" | "issue" | "i" => Some("issue"),
|
||||
"mrs" | "mr" | "m" | "merge_request" => Some("merge_request"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(etype) = entity_type {
|
||||
// Entity mode
|
||||
let iid = iid.ok_or_else(|| {
|
||||
LoreError::Other("Entity mode requires an IID (e.g., 'lore related issues 42')".into())
|
||||
})?;
|
||||
run_related_entity(&conn, config, etype, iid, limit, project).await
|
||||
} else {
|
||||
// Query mode - treat query_or_type as free text
|
||||
run_related_query(&conn, config, query_or_type, limit, project).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_related_entity(
|
||||
conn: &Connection,
|
||||
config: &Config,
|
||||
entity_type: &str,
|
||||
iid: i64,
|
||||
limit: usize,
|
||||
project_filter: Option<&str>,
|
||||
) -> Result<RelatedResponse> {
|
||||
// Find the source document
|
||||
let source_doc = find_entity_document(conn, entity_type, iid, project_filter)?;
|
||||
let source_info = get_entity_info(conn, entity_type, source_doc.source_id)?;
|
||||
|
||||
// Embed the source content
|
||||
let embedding = embed_text(config, &source_doc.content_text).await?;
|
||||
|
||||
// Search for similar documents (limit + 1 to account for filtering self)
|
||||
let vector_results = search_vector(conn, &embedding, limit.saturating_add(1))?;
|
||||
|
||||
// Filter out self and hydrate results
|
||||
let source_labels = parse_label_names(&source_doc.label_names);
|
||||
let mut results = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for vr in vector_results {
|
||||
// Skip self
|
||||
if vr.document_id == source_doc.id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(result) = hydrate_result(conn, vr.document_id, vr.distance, &source_labels)? {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if results.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for low similarity
|
||||
if !results.is_empty() && results.iter().all(|r| r.similarity_score < 0.3) {
|
||||
warnings.push("No strongly related entities found (all scores < 0.3)".to_string());
|
||||
}
|
||||
|
||||
Ok(RelatedResponse {
|
||||
mode: "entity".to_string(),
|
||||
source: Some(RelatedSource {
|
||||
source_type: entity_type.to_string(),
|
||||
iid,
|
||||
title: source_info.title,
|
||||
project_path: source_info.project_path,
|
||||
}),
|
||||
query: None,
|
||||
results,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_related_query(
|
||||
conn: &Connection,
|
||||
config: &Config,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
project_filter: Option<&str>,
|
||||
) -> Result<RelatedResponse> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// Warn if query is very short
|
||||
if query.split_whitespace().count() <= 2 {
|
||||
warnings.push("Short queries may produce noisy results".to_string());
|
||||
}
|
||||
|
||||
// Embed the query
|
||||
let embedding = embed_text(config, query).await?;
|
||||
|
||||
// Search for similar documents (fetch extra to allow for project filtering)
|
||||
let vector_results = search_vector(conn, &embedding, limit.saturating_mul(2))?;
|
||||
|
||||
// Filter by project if specified and hydrate
|
||||
let project_id = project_filter
|
||||
.map(|p| resolve_project(conn, p))
|
||||
.transpose()?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
let empty_labels: HashSet<String> = HashSet::new();
|
||||
|
||||
for vr in vector_results {
|
||||
// Check project filter
|
||||
if let Some(pid) = project_id {
|
||||
let doc_project_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT project_id FROM documents WHERE id = ?1",
|
||||
[vr.document_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if doc_project_id != Some(pid) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(result) = hydrate_result(conn, vr.document_id, vr.distance, &empty_labels)? {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if results.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for low similarity
|
||||
if !results.is_empty() && results.iter().all(|r| r.similarity_score < 0.3) {
|
||||
warnings.push("No strongly related entities found (all scores < 0.3)".to_string());
|
||||
}
|
||||
|
||||
Ok(RelatedResponse {
|
||||
mode: "query".to_string(),
|
||||
source: None,
|
||||
query: Some(query.to_string()),
|
||||
results,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn find_entity_document(
|
||||
conn: &Connection,
|
||||
entity_type: &str,
|
||||
iid: i64,
|
||||
project_filter: Option<&str>,
|
||||
) -> Result<DocumentRow> {
|
||||
let table = match entity_type {
|
||||
"issue" => "issues",
|
||||
"merge_request" => "merge_requests",
|
||||
_ => {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Unknown entity type: {entity_type}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
|
||||
Some(project) => {
|
||||
let project_id = resolve_project(conn, project)?;
|
||||
(
|
||||
format!(
|
||||
"SELECT d.id, d.source_type, d.source_id, d.project_id, d.title, d.url,
|
||||
d.content_text, d.label_names, d.author_username, d.updated_at
|
||||
FROM documents d
|
||||
JOIN {table} e ON d.source_id = e.id
|
||||
WHERE d.source_type = ?1 AND e.iid = ?2 AND e.project_id = ?3"
|
||||
),
|
||||
vec![
|
||||
Box::new(entity_type.to_string()),
|
||||
Box::new(iid),
|
||||
Box::new(project_id),
|
||||
],
|
||||
)
|
||||
}
|
||||
None => (
|
||||
format!(
|
||||
"SELECT d.id, d.source_type, d.source_id, d.project_id, d.title, d.url,
|
||||
d.content_text, d.label_names, d.author_username, d.updated_at
|
||||
FROM documents d
|
||||
JOIN {table} e ON d.source_id = e.id
|
||||
WHERE d.source_type = ?1 AND e.iid = ?2"
|
||||
),
|
||||
vec![Box::new(entity_type.to_string()), Box::new(iid)],
|
||||
),
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows: Vec<DocumentRow> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(DocumentRow {
|
||||
id: row.get(0)?,
|
||||
source_type: row.get(1)?,
|
||||
source_id: row.get(2)?,
|
||||
project_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
url: row.get(5)?,
|
||||
content_text: row.get(6)?,
|
||||
label_names: row.get(7)?,
|
||||
author_username: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
match rows.len() {
|
||||
0 => Err(LoreError::NotFound(format!(
|
||||
"{entity_type} #{iid} not found (run 'lore sync' first?)"
|
||||
))),
|
||||
1 => Ok(rows.into_iter().next().unwrap()),
|
||||
_ => Err(LoreError::Ambiguous(format!(
|
||||
"{entity_type} #{iid} exists in multiple projects. Use --project to specify."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_entity_info(conn: &Connection, entity_type: &str, entity_id: i64) -> Result<EntityInfo> {
|
||||
let table = match entity_type {
|
||||
"issue" => "issues",
|
||||
"merge_request" => "merge_requests",
|
||||
_ => {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Unknown entity type: {entity_type}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT e.iid, e.title, p.path_with_namespace
|
||||
FROM {table} e
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
WHERE e.id = ?1"
|
||||
);
|
||||
|
||||
conn.query_row(&sql, [entity_id], |row| {
|
||||
Ok(EntityInfo {
|
||||
iid: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
project_path: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| LoreError::NotFound(format!("Entity not found: {e}")))
|
||||
}
|
||||
|
||||
fn hydrate_result(
|
||||
conn: &Connection,
|
||||
document_id: i64,
|
||||
distance: f64,
|
||||
source_labels: &HashSet<String>,
|
||||
) -> Result<Option<RelatedResult>> {
|
||||
let doc: Option<DocumentRow> = conn
|
||||
.query_row(
|
||||
"SELECT d.id, d.source_type, d.source_id, d.project_id, d.title, d.url,
|
||||
d.content_text, d.label_names, d.author_username, d.updated_at
|
||||
FROM documents d
|
||||
WHERE d.id = ?1",
|
||||
[document_id],
|
||||
|row| {
|
||||
Ok(DocumentRow {
|
||||
id: row.get(0)?,
|
||||
source_type: row.get(1)?,
|
||||
source_id: row.get(2)?,
|
||||
project_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
url: row.get(5)?,
|
||||
content_text: row.get(6)?,
|
||||
label_names: row.get(7)?,
|
||||
author_username: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let Some(doc) = doc else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Skip discussion/note documents - we want entities only
|
||||
if doc.source_type == "discussion" || doc.source_type == "note" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get IID from the source entity
|
||||
let table = match doc.source_type.as_str() {
|
||||
"issue" => "issues",
|
||||
"merge_request" => "merge_requests",
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
// Get IID and title from the source entity - skip gracefully if not found
|
||||
// (this handles orphaned documents where the entity was deleted)
|
||||
let entity_info: Option<(i64, String, String)> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT e.iid, e.title, p.path_with_namespace
|
||||
FROM {table} e
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
WHERE e.id = ?1"
|
||||
),
|
||||
[doc.source_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let Some((iid, title, project_path)) = entity_info else {
|
||||
// Entity not found in database - skip this result
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Compute shared labels
|
||||
let result_labels = parse_label_names(&doc.label_names);
|
||||
let shared_labels: Vec<String> = source_labels
|
||||
.intersection(&result_labels)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(Some(RelatedResult {
|
||||
source_type: doc.source_type,
|
||||
iid,
|
||||
title,
|
||||
url: doc.url.unwrap_or_default(),
|
||||
similarity_score: distance_to_similarity(distance),
|
||||
project_path,
|
||||
shared_labels,
|
||||
author: doc.author_username,
|
||||
updated_at: doc.updated_at.map(ms_to_iso).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedding helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn embed_text(config: &Config, text: &str) -> Result<Vec<f32>> {
|
||||
let ollama = OllamaClient::new(OllamaConfig {
|
||||
base_url: config.embedding.base_url.clone(),
|
||||
model: config.embedding.model.clone(),
|
||||
timeout_secs: 60,
|
||||
});
|
||||
|
||||
let embeddings = ollama.embed_batch(&[text]).await?;
|
||||
embeddings
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| LoreError::EmbeddingFailed {
|
||||
document_id: 0,
|
||||
reason: "No embedding returned".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert L2 distance to a 0-1 similarity score.
|
||||
/// Uses inverse relationship: closer (lower distance) = higher similarity.
|
||||
fn distance_to_similarity(distance: f64) -> f64 {
|
||||
1.0 / (1.0 + distance)
|
||||
}
|
||||
|
||||
fn parse_label_names(label_names_json: &Option<String>) -> HashSet<String> {
|
||||
label_names_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Printers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn print_related_human(response: &RelatedResponse) {
|
||||
// Header
|
||||
let header = match &response.source {
|
||||
Some(src) => format!("Related to {} #{}: {}", src.source_type, src.iid, src.title),
|
||||
None => format!(
|
||||
"Related to query: \"{}\"",
|
||||
response.query.as_deref().unwrap_or("")
|
||||
),
|
||||
};
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "-".repeat(header.len().min(70)));
|
||||
println!();
|
||||
|
||||
if response.results.is_empty() {
|
||||
println!("No related entities found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, result) in response.results.iter().enumerate() {
|
||||
let type_icon = match result.source_type.as_str() {
|
||||
"issue" => Icons::issue_opened(),
|
||||
"merge_request" => Icons::mr_opened(),
|
||||
_ => " ",
|
||||
};
|
||||
|
||||
let score_bar_len = (result.similarity_score * 10.0) as usize;
|
||||
let score_bar: String = "\u{2588}".repeat(score_bar_len);
|
||||
|
||||
println!(
|
||||
"{:>2}. {} {} #{} ({:.0}%) {}",
|
||||
i + 1,
|
||||
type_icon,
|
||||
result.source_type,
|
||||
result.iid,
|
||||
result.similarity_score * 100.0,
|
||||
score_bar
|
||||
);
|
||||
println!(" {}", result.title);
|
||||
println!(
|
||||
" {} | @{}",
|
||||
result.project_path,
|
||||
result.author.as_deref().unwrap_or("?")
|
||||
);
|
||||
|
||||
if !result.shared_labels.is_empty() {
|
||||
println!(" Labels shared: {}", result.shared_labels.join(", "));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// Warnings
|
||||
for warning in &response.warnings {
|
||||
println!("{} {}", Theme::warning().render(Icons::warning()), warning);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_related_json(response: &RelatedResponse, elapsed_ms: u64) {
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": response,
|
||||
"meta": meta,
|
||||
});
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_distance_to_similarity_identical() {
|
||||
assert!((distance_to_similarity(0.0) - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distance_to_similarity_midpoint() {
|
||||
assert!((distance_to_similarity(1.0) - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distance_to_similarity_large() {
|
||||
let sim = distance_to_similarity(2.0);
|
||||
assert!(sim > 0.0 && sim < 0.5);
|
||||
assert!((sim - 0.333_333_333_333_333_3).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distance_to_similarity_range() {
|
||||
for d in [0.0, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0] {
|
||||
let sim = distance_to_similarity(d);
|
||||
assert!(
|
||||
sim > 0.0 && sim <= 1.0,
|
||||
"score {sim} out of range for distance {d}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_label_names_valid() {
|
||||
let json = Some(r#"["bug", "priority::high"]"#.to_string());
|
||||
let labels = parse_label_names(&json);
|
||||
assert!(labels.contains("bug"));
|
||||
assert!(labels.contains("priority::high"));
|
||||
assert_eq!(labels.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_label_names_empty() {
|
||||
let labels = parse_label_names(&None);
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_label_names_invalid_json() {
|
||||
let json = Some("not valid json".to_string());
|
||||
let labels = parse_label_names(&json);
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_label_names_empty_array() {
|
||||
let json = Some("[]".to_string());
|
||||
let labels = parse_label_names(&json);
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
@@ -309,67 +309,93 @@ fn parse_json_array(json: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render FTS snippet with `<mark>` tags as terminal highlight style.
|
||||
fn render_snippet(snippet: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = snippet;
|
||||
while let Some(start) = remaining.find("<mark>") {
|
||||
result.push_str(&Theme::muted().render(&remaining[..start]));
|
||||
remaining = &remaining[start + 6..];
|
||||
if let Some(end) = remaining.find("</mark>") {
|
||||
let highlighted = &remaining[..end];
|
||||
result.push_str(&Theme::highlight().render(highlighted));
|
||||
remaining = &remaining[end + 7..];
|
||||
}
|
||||
}
|
||||
result.push_str(&Theme::muted().render(remaining));
|
||||
result
|
||||
}
|
||||
|
||||
pub fn print_search_results(response: &SearchResponse) {
|
||||
if !response.warnings.is_empty() {
|
||||
for w in &response.warnings {
|
||||
eprintln!("{} {}", style("Warning:").yellow(), w);
|
||||
eprintln!("{} {}", Theme::warning().render("Warning:"), w);
|
||||
}
|
||||
}
|
||||
|
||||
if response.results.is_empty() {
|
||||
println!("No results found for '{}'", style(&response.query).bold());
|
||||
println!(
|
||||
"No results found for '{}'",
|
||||
Theme::bold().render(&response.query)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} results for '{}' ({})",
|
||||
response.total_results,
|
||||
style(&response.query).bold(),
|
||||
response.mode
|
||||
"\n {} results for '{}' {}",
|
||||
Theme::bold().render(&response.total_results.to_string()),
|
||||
Theme::bold().render(&response.query),
|
||||
Theme::muted().render(&response.mode)
|
||||
);
|
||||
println!();
|
||||
|
||||
for (i, result) in response.results.iter().enumerate() {
|
||||
let type_prefix = match result.source_type.as_str() {
|
||||
"issue" => "Issue",
|
||||
"merge_request" => "MR",
|
||||
"discussion" => "Discussion",
|
||||
_ => &result.source_type,
|
||||
println!();
|
||||
|
||||
let type_badge = match result.source_type.as_str() {
|
||||
"issue" => Theme::issue_ref().render("issue"),
|
||||
"merge_request" => Theme::mr_ref().render(" mr "),
|
||||
"discussion" => Theme::info().render(" disc"),
|
||||
"note" => Theme::muted().render(" note"),
|
||||
_ => Theme::muted().render(&format!("{:>5}", &result.source_type)),
|
||||
};
|
||||
|
||||
// Title line: rank, type badge, title
|
||||
println!(
|
||||
"[{}] {} - {} (score: {:.2})",
|
||||
i + 1,
|
||||
style(type_prefix).cyan(),
|
||||
result.title,
|
||||
result.score
|
||||
" {:>3}. {} {}",
|
||||
Theme::muted().render(&(i + 1).to_string()),
|
||||
type_badge,
|
||||
Theme::bold().render(&result.title)
|
||||
);
|
||||
|
||||
if let Some(ref url) = result.url {
|
||||
println!(" {}", style(url).dim());
|
||||
// Metadata: project, author, labels — compact middle-dot line
|
||||
let sep = Theme::muted().render(" \u{b7} ");
|
||||
let mut meta_parts: Vec<String> = Vec::new();
|
||||
meta_parts.push(Theme::muted().render(&result.project_path));
|
||||
if let Some(ref author) = result.author {
|
||||
meta_parts.push(Theme::username().render(&format!("@{author}")));
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} | {}",
|
||||
style(&result.project_path).dim(),
|
||||
result
|
||||
.author
|
||||
.as_deref()
|
||||
.map(|a| format!("@{}", a))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
if !result.labels.is_empty() {
|
||||
println!(" Labels: {}", result.labels.join(", "));
|
||||
let label_str = if result.labels.len() <= 3 {
|
||||
result.labels.join(", ")
|
||||
} else {
|
||||
format!(
|
||||
"{} +{}",
|
||||
result.labels[..2].join(", "),
|
||||
result.labels.len() - 2
|
||||
)
|
||||
};
|
||||
meta_parts.push(Theme::muted().render(&label_str));
|
||||
}
|
||||
println!(" {}", meta_parts.join(&sep));
|
||||
|
||||
let clean_snippet = result.snippet.replace("<mark>", "").replace("</mark>", "");
|
||||
println!(" {}", style(clean_snippet).dim());
|
||||
// Snippet with highlight styling
|
||||
let rendered = render_snippet(&result.snippet);
|
||||
println!(" {rendered}");
|
||||
|
||||
if let Some(ref explain) = result.explain {
|
||||
println!(
|
||||
" {} vector_rank={} fts_rank={} rrf_score={:.6}",
|
||||
style("[explain]").magenta(),
|
||||
" {} vec={} fts={} rrf={:.4}",
|
||||
Theme::accent().render("explain"),
|
||||
explain
|
||||
.vector_rank
|
||||
.map(|r| r.to_string())
|
||||
@@ -381,9 +407,9 @@ pub fn print_search_results(response: &SearchResponse) {
|
||||
explain.rrf_score
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -413,5 +439,8 @@ pub fn print_search_results_json(
|
||||
let expanded = crate::cli::robot::expand_fields_preset(f, "search");
|
||||
crate::cli::robot::filter_fields(&mut value, "results", &expanded);
|
||||
}
|
||||
println!("{}", serde_json::to_string(&value).unwrap());
|
||||
match serde_json::to_string(&value) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -160,6 +160,7 @@ pub fn run_show_issue(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IssueRow {
|
||||
id: i64,
|
||||
iid: i64,
|
||||
@@ -194,7 +195,7 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
i.due_date, i.milestone_title,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
WHERE d.noteable_type = 'Issue' AND d.noteable_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||
WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||
i.status_name, i.status_category, i.status_color,
|
||||
i.status_icon_name, i.status_synced_at
|
||||
FROM issues i
|
||||
@@ -210,7 +211,7 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
i.due_date, i.milestone_title,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
WHERE d.noteable_type = 'Issue' AND d.noteable_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||
WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||
i.status_name, i.status_category, i.status_color,
|
||||
i.status_icon_name, i.status_synced_at
|
||||
FROM issues i
|
||||
@@ -605,69 +606,55 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
|
||||
}
|
||||
|
||||
fn format_date(ms: i64) -> String {
|
||||
let iso = ms_to_iso(ms);
|
||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
||||
render::format_date(ms)
|
||||
}
|
||||
|
||||
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut current_line = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
if current_line.is_empty() {
|
||||
current_line = word.to_string();
|
||||
} else if current_line.len() + 1 + word.len() <= width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(word);
|
||||
} else {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
result.push_str(indent);
|
||||
}
|
||||
result.push_str(¤t_line);
|
||||
current_line = word.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
result.push_str(indent);
|
||||
}
|
||||
result.push_str(¤t_line);
|
||||
}
|
||||
|
||||
result
|
||||
render::wrap_indent(text, width, indent)
|
||||
}
|
||||
|
||||
pub fn print_show_issue(issue: &IssueDetail) {
|
||||
let header = format!("Issue #{}: {}", issue.iid, issue.title);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", "━".repeat(header.len().min(80)));
|
||||
println!();
|
||||
// Title line
|
||||
println!(
|
||||
" Issue #{}: {}",
|
||||
issue.iid,
|
||||
Theme::bold().render(&issue.title),
|
||||
);
|
||||
|
||||
println!("Ref: {}", style(&issue.references_full).dim());
|
||||
println!("Project: {}", style(&issue.project_path).cyan());
|
||||
// Details section
|
||||
println!("{}", render::section_divider("Details"));
|
||||
|
||||
let state_styled = if issue.state == "opened" {
|
||||
style(&issue.state).green()
|
||||
println!(
|
||||
" Ref {}",
|
||||
Theme::muted().render(&issue.references_full)
|
||||
);
|
||||
println!(
|
||||
" Project {}",
|
||||
Theme::info().render(&issue.project_path)
|
||||
);
|
||||
|
||||
let (icon, state_style) = if issue.state == "opened" {
|
||||
(Icons::issue_opened(), Theme::success())
|
||||
} else {
|
||||
style(&issue.state).dim()
|
||||
(Icons::issue_closed(), Theme::dim())
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
|
||||
if issue.confidential {
|
||||
println!(" {}", style("CONFIDENTIAL").red().bold());
|
||||
}
|
||||
println!(
|
||||
" State {}",
|
||||
state_style.render(&format!("{icon} {}", issue.state))
|
||||
);
|
||||
|
||||
if let Some(status) = &issue.status_name {
|
||||
println!(
|
||||
"Status: {}",
|
||||
style_with_hex(status, issue.status_color.as_deref())
|
||||
" Status {}",
|
||||
render::style_with_hex(status, issue.status_color.as_deref())
|
||||
);
|
||||
}
|
||||
|
||||
println!("Author: @{}", issue.author_username);
|
||||
if issue.confidential {
|
||||
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
||||
}
|
||||
|
||||
println!(" Author @{}", issue.author_username);
|
||||
|
||||
if !issue.assignees.is_empty() {
|
||||
let label = if issue.assignees.len() > 1 {
|
||||
@@ -676,69 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
"Assignee"
|
||||
};
|
||||
println!(
|
||||
"{}:{} {}",
|
||||
" {}{} {}",
|
||||
label,
|
||||
" ".repeat(10 - label.len()),
|
||||
" ".repeat(12 - label.len()),
|
||||
issue
|
||||
.assignees
|
||||
.iter()
|
||||
.map(|a| format!("@{}", a))
|
||||
.map(|a| format!("@{a}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
println!("Created: {}", format_date(issue.created_at));
|
||||
println!("Updated: {}", format_date(issue.updated_at));
|
||||
println!(
|
||||
" Created {} ({})",
|
||||
format_date(issue.created_at),
|
||||
render::format_relative_time_compact(issue.created_at),
|
||||
);
|
||||
println!(
|
||||
" Updated {} ({})",
|
||||
format_date(issue.updated_at),
|
||||
render::format_relative_time_compact(issue.updated_at),
|
||||
);
|
||||
|
||||
if let Some(closed_at) = &issue.closed_at {
|
||||
println!("Closed: {}", closed_at);
|
||||
println!(" Closed {closed_at}");
|
||||
}
|
||||
|
||||
if let Some(due) = &issue.due_date {
|
||||
println!("Due: {}", due);
|
||||
println!(" Due {due}");
|
||||
}
|
||||
|
||||
if let Some(ms) = &issue.milestone {
|
||||
println!("Milestone: {}", ms);
|
||||
println!(" Milestone {ms}");
|
||||
}
|
||||
|
||||
if issue.labels.is_empty() {
|
||||
println!("Labels: {}", style("(none)").dim());
|
||||
} else {
|
||||
println!("Labels: {}", issue.labels.join(", "));
|
||||
}
|
||||
|
||||
if !issue.closing_merge_requests.is_empty() {
|
||||
println!();
|
||||
println!("{}", style("Development:").bold());
|
||||
for mr in &issue.closing_merge_requests {
|
||||
let state_indicator = match mr.state.as_str() {
|
||||
"merged" => style(&mr.state).green(),
|
||||
"opened" => style(&mr.state).cyan(),
|
||||
"closed" => style(&mr.state).red(),
|
||||
_ => style(&mr.state).dim(),
|
||||
};
|
||||
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
|
||||
}
|
||||
if !issue.labels.is_empty() {
|
||||
println!(
|
||||
" Labels {}",
|
||||
render::format_labels_bare(&issue.labels, issue.labels.len())
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(url) = &issue.web_url {
|
||||
println!("URL: {}", style(url).dim());
|
||||
println!(" URL {}", Theme::muted().render(url));
|
||||
}
|
||||
|
||||
println!();
|
||||
// Development section
|
||||
if !issue.closing_merge_requests.is_empty() {
|
||||
println!("{}", render::section_divider("Development"));
|
||||
for mr in &issue.closing_merge_requests {
|
||||
let (mr_icon, mr_style) = match mr.state.as_str() {
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
println!(
|
||||
" {} !{} {} {}",
|
||||
mr_style.render(mr_icon),
|
||||
mr.iid,
|
||||
mr.title,
|
||||
mr_style.render(&mr.state),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", style("Description:").bold());
|
||||
// Description section
|
||||
println!("{}", render::section_divider("Description"));
|
||||
if let Some(desc) = &issue.description {
|
||||
let wrapped = wrap_text(desc, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(desc, 72, " ");
|
||||
println!(" {wrapped}");
|
||||
} else {
|
||||
println!(" {}", style("(no description)").dim());
|
||||
println!(" {}", Theme::muted().render("(no description)"));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Discussions section
|
||||
let user_discussions: Vec<&DiscussionDetail> = issue
|
||||
.discussions
|
||||
.iter()
|
||||
@@ -746,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
.collect();
|
||||
|
||||
if user_discussions.is_empty() {
|
||||
println!("{}", style("Discussions: (none)").dim());
|
||||
println!("\n {}", Theme::muted().render("No discussions"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
||||
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||
);
|
||||
println!();
|
||||
|
||||
for discussion in user_discussions {
|
||||
let user_notes: Vec<&NoteDetail> =
|
||||
@@ -760,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
|
||||
if let Some(first_note) = user_notes.first() {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", first_note.author_username)).cyan(),
|
||||
format_date(first_note.created_at)
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||
format_date(first_note.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", reply.author_username)).cyan(),
|
||||
format_date(reply.created_at)
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||
format_date(reply.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
@@ -784,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
}
|
||||
|
||||
pub fn print_show_mr(mr: &MrDetail) {
|
||||
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
|
||||
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", "━".repeat(header.len().min(80)));
|
||||
println!();
|
||||
|
||||
println!("Project: {}", style(&mr.project_path).cyan());
|
||||
|
||||
let state_styled = match mr.state.as_str() {
|
||||
"opened" => style(&mr.state).green(),
|
||||
"merged" => style(&mr.state).magenta(),
|
||||
"closed" => style(&mr.state).red(),
|
||||
_ => style(&mr.state).dim(),
|
||||
// Title line
|
||||
let draft_prefix = if mr.draft {
|
||||
format!("{} ", Icons::mr_draft())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
|
||||
println!(
|
||||
"Branches: {} -> {}",
|
||||
style(&mr.source_branch).cyan(),
|
||||
style(&mr.target_branch).yellow()
|
||||
" MR !{}: {}{}",
|
||||
mr.iid,
|
||||
draft_prefix,
|
||||
Theme::bold().render(&mr.title),
|
||||
);
|
||||
|
||||
println!("Author: @{}", mr.author_username);
|
||||
// Details section
|
||||
println!("{}", render::section_divider("Details"));
|
||||
|
||||
println!(" Project {}", Theme::info().render(&mr.project_path));
|
||||
|
||||
let (icon, state_style) = match mr.state.as_str() {
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
println!(
|
||||
" State {}",
|
||||
state_style.render(&format!("{icon} {}", mr.state))
|
||||
);
|
||||
|
||||
println!(
|
||||
" Branches {} -> {}",
|
||||
Theme::info().render(&mr.source_branch),
|
||||
Theme::warning().render(&mr.target_branch)
|
||||
);
|
||||
|
||||
println!(" Author @{}", mr.author_username);
|
||||
|
||||
if !mr.assignees.is_empty() {
|
||||
println!(
|
||||
"Assignees: {}",
|
||||
" Assignees {}",
|
||||
mr.assignees
|
||||
.iter()
|
||||
.map(|a| format!("@{}", a))
|
||||
.map(|a| format!("@{a}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
@@ -821,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
|
||||
if !mr.reviewers.is_empty() {
|
||||
println!(
|
||||
"Reviewers: {}",
|
||||
" Reviewers {}",
|
||||
mr.reviewers
|
||||
.iter()
|
||||
.map(|r| format!("@{}", r))
|
||||
.map(|r| format!("@{r}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
println!("Created: {}", format_date(mr.created_at));
|
||||
println!("Updated: {}", format_date(mr.updated_at));
|
||||
println!(
|
||||
" Created {} ({})",
|
||||
format_date(mr.created_at),
|
||||
render::format_relative_time_compact(mr.created_at),
|
||||
);
|
||||
println!(
|
||||
" Updated {} ({})",
|
||||
format_date(mr.updated_at),
|
||||
render::format_relative_time_compact(mr.updated_at),
|
||||
);
|
||||
|
||||
if let Some(merged_at) = mr.merged_at {
|
||||
println!("Merged: {}", format_date(merged_at));
|
||||
println!(
|
||||
" Merged {} ({})",
|
||||
format_date(merged_at),
|
||||
render::format_relative_time_compact(merged_at),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(closed_at) = mr.closed_at {
|
||||
println!("Closed: {}", format_date(closed_at));
|
||||
println!(
|
||||
" Closed {} ({})",
|
||||
format_date(closed_at),
|
||||
render::format_relative_time_compact(closed_at),
|
||||
);
|
||||
}
|
||||
|
||||
if mr.labels.is_empty() {
|
||||
println!("Labels: {}", style("(none)").dim());
|
||||
} else {
|
||||
println!("Labels: {}", mr.labels.join(", "));
|
||||
if !mr.labels.is_empty() {
|
||||
println!(
|
||||
" Labels {}",
|
||||
render::format_labels_bare(&mr.labels, mr.labels.len())
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(url) = &mr.web_url {
|
||||
println!("URL: {}", style(url).dim());
|
||||
println!(" URL {}", Theme::muted().render(url));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Description:").bold());
|
||||
// Description section
|
||||
println!("{}", render::section_divider("Description"));
|
||||
if let Some(desc) = &mr.description {
|
||||
let wrapped = wrap_text(desc, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(desc, 72, " ");
|
||||
println!(" {wrapped}");
|
||||
} else {
|
||||
println!(" {}", style("(no description)").dim());
|
||||
println!(" {}", Theme::muted().render("(no description)"));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Discussions section
|
||||
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
||||
.discussions
|
||||
.iter()
|
||||
@@ -870,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
.collect();
|
||||
|
||||
if user_discussions.is_empty() {
|
||||
println!("{}", style("Discussions: (none)").dim());
|
||||
println!("\n {}", Theme::muted().render("No discussions"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
||||
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||
);
|
||||
println!();
|
||||
|
||||
for discussion in user_discussions {
|
||||
let user_notes: Vec<&MrNoteDetail> =
|
||||
@@ -888,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", first_note.author_username)).cyan(),
|
||||
format_date(first_note.created_at)
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||
format_date(first_note.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", reply.author_username)).cyan(),
|
||||
format_date(reply.created_at)
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||
format_date(reply.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
@@ -925,39 +951,13 @@ fn print_diff_position(pos: &DiffNotePosition) {
|
||||
|
||||
println!(
|
||||
" {} {}{}",
|
||||
style("📍").dim(),
|
||||
style(file_path).yellow(),
|
||||
style(line_str).dim()
|
||||
Theme::dim().render("\u{1f4cd}"),
|
||||
Theme::warning().render(file_path),
|
||||
Theme::dim().render(&line_str)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
|
||||
let styled = console::style(text);
|
||||
let Some(hex) = hex else { return styled };
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() != 6 {
|
||||
return styled;
|
||||
}
|
||||
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
||||
return styled;
|
||||
};
|
||||
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
||||
return styled;
|
||||
};
|
||||
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
||||
return styled;
|
||||
};
|
||||
styled.color256(ansi256_from_rgb(r, g, b))
|
||||
}
|
||||
|
||||
fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
|
||||
let ri = (u16::from(r) * 5 + 127) / 255;
|
||||
let gi = (u16::from(g) * 5 + 127) / 255;
|
||||
let bi = (u16::from(b) * 5 + 127) / 255;
|
||||
(16 + 36 * ri + 6 * gi + bi) as u8
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IssueDetailJson {
|
||||
pub id: i64,
|
||||
@@ -1218,10 +1218,177 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_second_project(conn: &Connection) {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (2, 101, 'other/repo', 'https://gitlab.example.com/other', 1000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_discussion_with_notes(
|
||||
conn: &Connection,
|
||||
issue_id: i64,
|
||||
project_id: i64,
|
||||
user_notes: usize,
|
||||
system_notes: usize,
|
||||
) {
|
||||
let disc_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(id), 0) + 1 FROM discussions",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at)
|
||||
VALUES (?1, ?2, ?3, ?4, 'Issue', 1000, 2000, 2000)",
|
||||
rusqlite::params![disc_id, format!("disc-{}", disc_id), project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..user_notes {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
|
||||
VALUES (?1, ?2, ?3, 'user1', 'comment', 1000, 2000, 2000, 0, ?4)",
|
||||
rusqlite::params![1000 + disc_id * 100 + i as i64, disc_id, project_id, i as i64],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for i in 0..system_notes {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
|
||||
VALUES (?1, ?2, ?3, 'system', 'status changed', 1000, 2000, 2000, 1, ?4)",
|
||||
rusqlite::params![2000 + disc_id * 100 + i as i64, disc_id, project_id, (user_notes + i) as i64],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- find_issue tests ---
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_basic() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
let row = find_issue(&conn, 10, None).unwrap();
|
||||
assert_eq!(row.iid, 10);
|
||||
assert_eq!(row.title, "Test issue");
|
||||
assert_eq!(row.state, "opened");
|
||||
assert_eq!(row.author_username, "author");
|
||||
assert_eq!(row.project_path, "group/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_with_project_filter() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
let row = find_issue(&conn, 10, Some("group/repo")).unwrap();
|
||||
assert_eq!(row.iid, 10);
|
||||
assert_eq!(row.project_path, "group/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_not_found() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
let err = find_issue(&conn, 999, None).unwrap_err();
|
||||
assert!(matches!(err, LoreError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_wrong_project_filter() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
seed_second_project(&conn);
|
||||
// Issue 10 only exists in project 1, not project 2
|
||||
let err = find_issue(&conn, 10, Some("other/repo")).unwrap_err();
|
||||
assert!(matches!(err, LoreError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_ambiguous_without_project() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn); // issue iid=10 in project 1
|
||||
seed_second_project(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
||||
created_at, updated_at, last_seen_at)
|
||||
VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let err = find_issue(&conn, 10, None).unwrap_err();
|
||||
assert!(matches!(err, LoreError::Ambiguous(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_ambiguous_resolved_with_project() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
seed_second_project(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
||||
created_at, updated_at, last_seen_at)
|
||||
VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let row = find_issue(&conn, 10, Some("other/repo")).unwrap();
|
||||
assert_eq!(row.title, "Same iid different project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_user_notes_count_zero() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
let row = find_issue(&conn, 10, None).unwrap();
|
||||
assert_eq!(row.user_notes_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_user_notes_count_excludes_system() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
// 2 user notes + 3 system notes = should count only 2
|
||||
seed_discussion_with_notes(&conn, 1, 1, 2, 3);
|
||||
let row = find_issue(&conn, 10, None).unwrap();
|
||||
assert_eq!(row.user_notes_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_user_notes_count_across_discussions() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
seed_discussion_with_notes(&conn, 1, 1, 3, 0); // 3 user notes
|
||||
seed_discussion_with_notes(&conn, 1, 1, 1, 2); // 1 user note + 2 system
|
||||
let row = find_issue(&conn, 10, None).unwrap();
|
||||
assert_eq!(row.user_notes_count, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_issue_notes_count_ignores_other_issues() {
|
||||
let conn = setup_test_db();
|
||||
seed_issue(&conn);
|
||||
// Add a second issue
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
||||
created_at, updated_at, last_seen_at)
|
||||
VALUES (2, 201, 20, 1, 'Other issue', 'opened', 'author', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Notes on issue 2, not issue 1
|
||||
seed_discussion_with_notes(&conn, 2, 1, 5, 0);
|
||||
let row = find_issue(&conn, 10, None).unwrap();
|
||||
assert_eq!(row.user_notes_count, 0); // Issue 10 has no notes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ansi256_from_rgb() {
|
||||
assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
|
||||
assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
|
||||
// Moved to render.rs — keeping basic hex sanity check
|
||||
let result = render::style_with_hex("test", Some("#ff0000"));
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -322,124 +322,206 @@ fn table_exists(conn: &Connection, table: &str) -> bool {
|
||||
> 0
|
||||
}
|
||||
|
||||
fn section(title: &str) {
|
||||
println!("{}", render::section_divider(title));
|
||||
}
|
||||
|
||||
pub fn print_stats(result: &StatsResult) {
|
||||
println!("{}", style("Documents").cyan().bold());
|
||||
println!(" Total: {}", result.documents.total);
|
||||
println!(" Issues: {}", result.documents.issues);
|
||||
println!(" Merge Requests: {}", result.documents.merge_requests);
|
||||
println!(" Discussions: {}", result.documents.discussions);
|
||||
section("Documents");
|
||||
let mut parts = vec![format!(
|
||||
"{} total",
|
||||
render::format_number(result.documents.total)
|
||||
)];
|
||||
if result.documents.issues > 0 {
|
||||
parts.push(format!(
|
||||
"{} issues",
|
||||
render::format_number(result.documents.issues)
|
||||
));
|
||||
}
|
||||
if result.documents.merge_requests > 0 {
|
||||
parts.push(format!(
|
||||
"{} MRs",
|
||||
render::format_number(result.documents.merge_requests)
|
||||
));
|
||||
}
|
||||
if result.documents.discussions > 0 {
|
||||
parts.push(format!(
|
||||
"{} discussions",
|
||||
render::format_number(result.documents.discussions)
|
||||
));
|
||||
}
|
||||
println!(" {}", parts.join(" \u{b7} "));
|
||||
if result.documents.truncated > 0 {
|
||||
println!(
|
||||
" Truncated: {}",
|
||||
style(result.documents.truncated).yellow()
|
||||
" {}",
|
||||
Theme::warning().render(&format!(
|
||||
"{} truncated",
|
||||
render::format_number(result.documents.truncated)
|
||||
))
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("{}", style("Search Index").cyan().bold());
|
||||
println!(" FTS indexed: {}", result.fts.indexed);
|
||||
section("Search Index");
|
||||
println!(
|
||||
" Embedding coverage: {:.1}% ({}/{})",
|
||||
result.embeddings.coverage_pct,
|
||||
result.embeddings.embedded_documents,
|
||||
result.documents.total
|
||||
" {} FTS indexed",
|
||||
render::format_number(result.fts.indexed)
|
||||
);
|
||||
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
|
||||
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
} else if result.embeddings.coverage_pct >= 50.0 {
|
||||
Theme::warning().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
} else {
|
||||
Theme::error().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
};
|
||||
println!(
|
||||
" {} embedding coverage ({}/{})",
|
||||
coverage_color,
|
||||
render::format_number(result.embeddings.embedded_documents),
|
||||
render::format_number(result.documents.total),
|
||||
);
|
||||
if result.embeddings.total_chunks > 0 {
|
||||
println!(" Total chunks: {}", result.embeddings.total_chunks);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("{}", style("Queues").cyan().bold());
|
||||
println!(
|
||||
" Dirty sources: {} pending, {} failed",
|
||||
result.queues.dirty_sources, result.queues.dirty_sources_failed
|
||||
);
|
||||
println!(
|
||||
" Discussion fetch: {} pending, {} failed",
|
||||
result.queues.pending_discussion_fetches, result.queues.pending_discussion_fetches_failed
|
||||
);
|
||||
if result.queues.pending_dependent_fetches > 0
|
||||
|| result.queues.pending_dependent_fetches_failed > 0
|
||||
|| result.queues.pending_dependent_fetches_stuck > 0
|
||||
{
|
||||
println!(
|
||||
" Dependent fetch: {} pending, {} failed, {} stuck",
|
||||
result.queues.pending_dependent_fetches,
|
||||
result.queues.pending_dependent_fetches_failed,
|
||||
result.queues.pending_dependent_fetches_stuck
|
||||
" {}",
|
||||
Theme::dim().render(&format!(
|
||||
"{} chunks",
|
||||
render::format_number(result.embeddings.total_chunks)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
// Queues: only show if there's anything to report
|
||||
let has_queue_activity = result.queues.dirty_sources > 0
|
||||
|| result.queues.dirty_sources_failed > 0
|
||||
|| result.queues.pending_discussion_fetches > 0
|
||||
|| result.queues.pending_discussion_fetches_failed > 0
|
||||
|| result.queues.pending_dependent_fetches > 0
|
||||
|| result.queues.pending_dependent_fetches_failed > 0;
|
||||
|
||||
if has_queue_activity {
|
||||
section("Queues");
|
||||
if result.queues.dirty_sources > 0 || result.queues.dirty_sources_failed > 0 {
|
||||
let mut q = Vec::new();
|
||||
if result.queues.dirty_sources > 0 {
|
||||
q.push(format!("{} pending", result.queues.dirty_sources));
|
||||
}
|
||||
if result.queues.dirty_sources_failed > 0 {
|
||||
q.push(
|
||||
Theme::error()
|
||||
.render(&format!("{} failed", result.queues.dirty_sources_failed)),
|
||||
);
|
||||
}
|
||||
println!(" dirty sources: {}", q.join(", "));
|
||||
}
|
||||
if result.queues.pending_discussion_fetches > 0
|
||||
|| result.queues.pending_discussion_fetches_failed > 0
|
||||
{
|
||||
let mut q = Vec::new();
|
||||
if result.queues.pending_discussion_fetches > 0 {
|
||||
q.push(format!(
|
||||
"{} pending",
|
||||
result.queues.pending_discussion_fetches
|
||||
));
|
||||
}
|
||||
if result.queues.pending_discussion_fetches_failed > 0 {
|
||||
q.push(Theme::error().render(&format!(
|
||||
"{} failed",
|
||||
result.queues.pending_discussion_fetches_failed
|
||||
)));
|
||||
}
|
||||
println!(" discussion fetch: {}", q.join(", "));
|
||||
}
|
||||
if result.queues.pending_dependent_fetches > 0
|
||||
|| result.queues.pending_dependent_fetches_failed > 0
|
||||
{
|
||||
let mut q = Vec::new();
|
||||
if result.queues.pending_dependent_fetches > 0 {
|
||||
q.push(format!(
|
||||
"{} pending",
|
||||
result.queues.pending_dependent_fetches
|
||||
));
|
||||
}
|
||||
if result.queues.pending_dependent_fetches_failed > 0 {
|
||||
q.push(Theme::error().render(&format!(
|
||||
"{} failed",
|
||||
result.queues.pending_dependent_fetches_failed
|
||||
)));
|
||||
}
|
||||
if result.queues.pending_dependent_fetches_stuck > 0 {
|
||||
q.push(Theme::warning().render(&format!(
|
||||
"{} stuck",
|
||||
result.queues.pending_dependent_fetches_stuck
|
||||
)));
|
||||
}
|
||||
println!(" dependent fetch: {}", q.join(", "));
|
||||
}
|
||||
} else {
|
||||
section("Queues");
|
||||
println!(" {}", Theme::success().render("all clear"));
|
||||
}
|
||||
|
||||
if let Some(ref integrity) = result.integrity {
|
||||
println!();
|
||||
let status = if integrity.ok {
|
||||
style("OK").green().bold()
|
||||
section("Integrity");
|
||||
if integrity.ok {
|
||||
println!(
|
||||
" {} all checks passed",
|
||||
Theme::success().render("\u{2713}")
|
||||
);
|
||||
} else {
|
||||
style("ISSUES FOUND").red().bold()
|
||||
};
|
||||
println!("{} Integrity: {}", style("Check").cyan().bold(), status);
|
||||
|
||||
if integrity.fts_doc_mismatch {
|
||||
println!(" {} FTS/document count mismatch", style("!").red());
|
||||
}
|
||||
if integrity.orphan_embeddings > 0 {
|
||||
println!(
|
||||
" {} {} orphan embeddings",
|
||||
style("!").red(),
|
||||
integrity.orphan_embeddings
|
||||
);
|
||||
}
|
||||
if integrity.stale_metadata > 0 {
|
||||
println!(
|
||||
" {} {} stale embedding metadata",
|
||||
style("!").red(),
|
||||
integrity.stale_metadata
|
||||
);
|
||||
}
|
||||
let orphan_events = integrity.orphan_state_events
|
||||
+ integrity.orphan_label_events
|
||||
+ integrity.orphan_milestone_events;
|
||||
if orphan_events > 0 {
|
||||
println!(
|
||||
" {} {} orphan resource events (state: {}, label: {}, milestone: {})",
|
||||
style("!").red(),
|
||||
orphan_events,
|
||||
integrity.orphan_state_events,
|
||||
integrity.orphan_label_events,
|
||||
integrity.orphan_milestone_events
|
||||
);
|
||||
}
|
||||
if integrity.queue_stuck_locks > 0 {
|
||||
println!(
|
||||
" {} {} stuck queue locks",
|
||||
style("!").yellow(),
|
||||
integrity.queue_stuck_locks
|
||||
);
|
||||
}
|
||||
if integrity.queue_max_attempts > 3 {
|
||||
println!(
|
||||
" {} max queue retry attempts: {}",
|
||||
style("!").yellow(),
|
||||
integrity.queue_max_attempts
|
||||
);
|
||||
if integrity.fts_doc_mismatch {
|
||||
println!(
|
||||
" {} FTS/document count mismatch",
|
||||
Theme::error().render("\u{2717}")
|
||||
);
|
||||
}
|
||||
if integrity.orphan_embeddings > 0 {
|
||||
println!(
|
||||
" {} {} orphan embeddings",
|
||||
Theme::error().render("\u{2717}"),
|
||||
integrity.orphan_embeddings
|
||||
);
|
||||
}
|
||||
if integrity.stale_metadata > 0 {
|
||||
println!(
|
||||
" {} {} stale embedding metadata",
|
||||
Theme::error().render("\u{2717}"),
|
||||
integrity.stale_metadata
|
||||
);
|
||||
}
|
||||
let orphan_events = integrity.orphan_state_events
|
||||
+ integrity.orphan_label_events
|
||||
+ integrity.orphan_milestone_events;
|
||||
if orphan_events > 0 {
|
||||
println!(
|
||||
" {} {} orphan resource events",
|
||||
Theme::error().render("\u{2717}"),
|
||||
orphan_events
|
||||
);
|
||||
}
|
||||
if integrity.queue_stuck_locks > 0 {
|
||||
println!(
|
||||
" {} {} stuck queue locks",
|
||||
Theme::warning().render("!"),
|
||||
integrity.queue_stuck_locks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref repair) = integrity.repair {
|
||||
println!();
|
||||
if repair.dry_run {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Repair").cyan().bold(),
|
||||
style("(dry run - no changes made)").yellow()
|
||||
" {} {}",
|
||||
Theme::bold().render("Repair"),
|
||||
Theme::warning().render("(dry run)")
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Repair").cyan().bold());
|
||||
println!(" {}", Theme::bold().render("Repair"));
|
||||
}
|
||||
|
||||
let action = if repair.dry_run {
|
||||
style("would fix").yellow()
|
||||
Theme::warning().render("would fix")
|
||||
} else {
|
||||
style("fixed").green()
|
||||
Theme::success().render("fixed")
|
||||
};
|
||||
|
||||
if repair.fts_rebuilt {
|
||||
@@ -453,15 +535,17 @@ pub fn print_stats(result: &StatsResult) {
|
||||
}
|
||||
if repair.stale_cleared > 0 {
|
||||
println!(
|
||||
" {} {} stale metadata entries cleared",
|
||||
" {} {} stale metadata cleared",
|
||||
action, repair.stale_cleared
|
||||
);
|
||||
}
|
||||
if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 {
|
||||
println!(" No issues to repair.");
|
||||
println!(" {}", Theme::dim().render("nothing to repair"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -501,5 +585,8 @@ pub fn print_stats_json(result: &StatsResult, elapsed_ms: u64) {
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -12,6 +12,10 @@ use crate::core::time::{format_full_datetime, ms_to_iso};
|
||||
|
||||
const RECENT_RUNS_LIMIT: usize = 10;
|
||||
|
||||
fn is_zero(value: &i64) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SyncRunInfo {
|
||||
pub id: i64,
|
||||
@@ -24,6 +28,15 @@ pub struct SyncRunInfo {
|
||||
pub total_items_processed: i64,
|
||||
pub total_errors: i64,
|
||||
pub stages: Option<Vec<StageTiming>>,
|
||||
// Per-entity counts (from migration 027)
|
||||
pub issues_fetched: i64,
|
||||
pub issues_ingested: i64,
|
||||
pub mrs_fetched: i64,
|
||||
pub mrs_ingested: i64,
|
||||
pub skipped_stale: i64,
|
||||
pub docs_regenerated: i64,
|
||||
pub docs_embedded: i64,
|
||||
pub warnings_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -68,7 +81,9 @@ pub fn run_sync_status(config: &Config) -> Result<SyncStatusResult> {
|
||||
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, started_at, finished_at, status, command, error,
|
||||
run_id, total_items_processed, total_errors, metrics_json
|
||||
run_id, total_items_processed, total_errors, metrics_json,
|
||||
issues_fetched, issues_ingested, mrs_fetched, mrs_ingested,
|
||||
skipped_stale, docs_regenerated, docs_embedded, warnings_count
|
||||
FROM sync_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?1",
|
||||
@@ -91,6 +106,14 @@ fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunIn
|
||||
total_items_processed: row.get::<_, Option<i64>>(7)?.unwrap_or(0),
|
||||
total_errors: row.get::<_, Option<i64>>(8)?.unwrap_or(0),
|
||||
stages,
|
||||
issues_fetched: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
|
||||
issues_ingested: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
|
||||
mrs_fetched: row.get::<_, Option<i64>>(12)?.unwrap_or(0),
|
||||
mrs_ingested: row.get::<_, Option<i64>>(13)?.unwrap_or(0),
|
||||
skipped_stale: row.get::<_, Option<i64>>(14)?.unwrap_or(0),
|
||||
docs_regenerated: row.get::<_, Option<i64>>(15)?.unwrap_or(0),
|
||||
docs_embedded: row.get::<_, Option<i64>>(16)?.unwrap_or(0),
|
||||
warnings_count: row.get::<_, Option<i64>>(17)?.unwrap_or(0),
|
||||
})
|
||||
})?
|
||||
.collect();
|
||||
@@ -166,27 +189,6 @@ fn format_duration(ms: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(n: i64) -> String {
|
||||
let is_negative = n < 0;
|
||||
let abs_n = n.unsigned_abs();
|
||||
let s = abs_n.to_string();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut result = String::new();
|
||||
|
||||
if is_negative {
|
||||
result.push('-');
|
||||
}
|
||||
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i > 0 && (chars.len() - i).is_multiple_of(3) {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(*c);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncStatusJsonOutput {
|
||||
ok: bool,
|
||||
@@ -219,6 +221,23 @@ struct SyncRunJsonInfo {
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stages: Option<Vec<StageTiming>>,
|
||||
// Per-entity counts
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
issues_fetched: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
issues_ingested: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
mrs_fetched: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
mrs_ingested: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
skipped_stale: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
docs_regenerated: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
docs_embedded: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
warnings_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -258,6 +277,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
|
||||
total_errors: run.total_errors,
|
||||
error: run.error.clone(),
|
||||
stages: run.stages.clone(),
|
||||
issues_fetched: run.issues_fetched,
|
||||
issues_ingested: run.issues_ingested,
|
||||
mrs_fetched: run.mrs_fetched,
|
||||
mrs_ingested: run.mrs_ingested,
|
||||
skipped_stale: run.skipped_stale,
|
||||
docs_regenerated: run.docs_regenerated,
|
||||
docs_embedded: run.docs_embedded,
|
||||
warnings_count: run.warnings_count,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -289,18 +316,21 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap());
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
println!("{}", style("Recent Sync Runs").bold().underlined());
|
||||
println!("{}", Theme::bold().underline().render("Recent Sync Runs"));
|
||||
println!();
|
||||
|
||||
if result.runs.is_empty() {
|
||||
println!(" {}", style("No sync runs recorded yet.").dim());
|
||||
println!(" {}", Theme::dim().render("No sync runs recorded yet."));
|
||||
println!(
|
||||
" {}",
|
||||
style("Run 'lore sync' or 'lore ingest' to start.").dim()
|
||||
Theme::dim().render("Run 'lore sync' or 'lore ingest' to start.")
|
||||
);
|
||||
} else {
|
||||
for run in &result.runs {
|
||||
@@ -310,16 +340,16 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Cursor Positions").bold().underlined());
|
||||
println!("{}", Theme::bold().underline().render("Cursor Positions"));
|
||||
println!();
|
||||
|
||||
if result.cursors.is_empty() {
|
||||
println!(" {}", style("No cursors recorded yet.").dim());
|
||||
println!(" {}", Theme::dim().render("No cursors recorded yet."));
|
||||
} else {
|
||||
for cursor in &result.cursors {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(&cursor.project_path).cyan(),
|
||||
Theme::info().render(&cursor.project_path),
|
||||
cursor.resource_type
|
||||
);
|
||||
|
||||
@@ -328,7 +358,10 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
println!(" Last updated_at: {}", ms_to_iso(ts));
|
||||
}
|
||||
_ => {
|
||||
println!(" Last updated_at: {}", style("Not started").dim());
|
||||
println!(
|
||||
" Last updated_at: {}",
|
||||
Theme::dim().render("Not started")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,40 +373,39 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Data Summary").bold().underlined());
|
||||
println!("{}", Theme::bold().underline().render("Data Summary"));
|
||||
println!();
|
||||
|
||||
println!(
|
||||
" Issues: {}",
|
||||
style(format_number(result.summary.issue_count)).bold()
|
||||
Theme::bold().render(&render::format_number(result.summary.issue_count))
|
||||
);
|
||||
println!(
|
||||
" MRs: {}",
|
||||
style(format_number(result.summary.mr_count)).bold()
|
||||
Theme::bold().render(&render::format_number(result.summary.mr_count))
|
||||
);
|
||||
println!(
|
||||
" Discussions: {}",
|
||||
style(format_number(result.summary.discussion_count)).bold()
|
||||
Theme::bold().render(&render::format_number(result.summary.discussion_count))
|
||||
);
|
||||
|
||||
let user_notes = result.summary.note_count - result.summary.system_note_count;
|
||||
println!(
|
||||
" Notes: {} {}",
|
||||
style(format_number(user_notes)).bold(),
|
||||
style(format!(
|
||||
Theme::bold().render(&render::format_number(user_notes)),
|
||||
Theme::dim().render(&format!(
|
||||
"(excluding {} system)",
|
||||
format_number(result.summary.system_note_count)
|
||||
render::format_number(result.summary.system_note_count)
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
|
||||
fn print_run_line(run: &SyncRunInfo) {
|
||||
let status_styled = match run.status.as_str() {
|
||||
"succeeded" => style(&run.status).green(),
|
||||
"failed" => style(&run.status).red(),
|
||||
"running" => style(&run.status).yellow(),
|
||||
_ => style(&run.status).dim(),
|
||||
"succeeded" => Theme::success().render(&run.status),
|
||||
"failed" => Theme::error().render(&run.status),
|
||||
"running" => Theme::warning().render(&run.status),
|
||||
_ => Theme::dim().render(&run.status),
|
||||
};
|
||||
|
||||
let run_label = run
|
||||
@@ -386,9 +418,9 @@ fn print_run_line(run: &SyncRunInfo) {
|
||||
let time = format_full_datetime(run.started_at);
|
||||
|
||||
let mut parts = vec![
|
||||
format!("{}", style(run_label).bold()),
|
||||
format!("{status_styled}"),
|
||||
format!("{}", style(&run.command).dim()),
|
||||
Theme::bold().render(&run_label),
|
||||
status_styled,
|
||||
Theme::dim().render(&run.command),
|
||||
time,
|
||||
];
|
||||
|
||||
@@ -403,16 +435,13 @@ fn print_run_line(run: &SyncRunInfo) {
|
||||
}
|
||||
|
||||
if run.total_errors > 0 {
|
||||
parts.push(format!(
|
||||
"{}",
|
||||
style(format!("{} errors", run.total_errors)).red()
|
||||
));
|
||||
parts.push(Theme::error().render(&format!("{} errors", run.total_errors)));
|
||||
}
|
||||
|
||||
println!(" {}", parts.join(" | "));
|
||||
|
||||
if let Some(error) = &run.error {
|
||||
println!(" {}", style(error).red());
|
||||
println!(" {}", Theme::error().render(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +477,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_number_adds_thousands_separators() {
|
||||
assert_eq!(format_number(1000), "1,000");
|
||||
assert_eq!(format_number(1234567), "1,234,567");
|
||||
assert_eq!(render::format_number(1000), "1,000");
|
||||
assert_eq!(render::format_number(1234567), "1,234,567");
|
||||
}
|
||||
}
|
||||
|
||||
711
src/cli/commands/sync_surgical.rs
Normal file
711
src/cli/commands/sync_surgical.rs
Normal file
@@ -0,0 +1,711 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use tracing::{Instrument, debug, info, warn};
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::commands::sync::{EntitySyncResult, SurgicalIids, SyncOptions, SyncResult};
|
||||
use crate::cli::progress::{format_stage_line, stage_spinner_v2};
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::core::db::{LATEST_SCHEMA_VERSION, create_connection, get_schema_version};
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::lock::{AppLock, LockOptions};
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::shutdown::ShutdownSignal;
|
||||
use crate::core::sync_run::SyncRunRecorder;
|
||||
use crate::documents::{SourceType, regenerate_dirty_documents_for_sources};
|
||||
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||
use crate::embedding::pipeline::{DEFAULT_EMBED_CONCURRENCY, embed_documents_by_ids};
|
||||
use crate::gitlab::GitLabClient;
|
||||
use crate::ingestion::surgical::{
|
||||
fetch_dependents_for_issue, fetch_dependents_for_mr, ingest_issue_by_iid, ingest_mr_by_iid,
|
||||
preflight_fetch,
|
||||
};
|
||||
|
||||
pub async fn run_sync_surgical(
|
||||
config: &Config,
|
||||
options: SyncOptions,
|
||||
run_id: Option<&str>,
|
||||
signal: &ShutdownSignal,
|
||||
) -> Result<SyncResult> {
|
||||
// ── Generate run_id ──
|
||||
let generated_id;
|
||||
let run_id = match run_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
generated_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
&generated_id[..8]
|
||||
}
|
||||
};
|
||||
let span = tracing::info_span!("surgical_sync", %run_id);
|
||||
|
||||
async move {
|
||||
let pipeline_start = Instant::now();
|
||||
let mut result = SyncResult {
|
||||
run_id: run_id.to_string(),
|
||||
surgical_mode: Some(true),
|
||||
surgical_iids: Some(SurgicalIids {
|
||||
issues: options.issue_iids.clone(),
|
||||
merge_requests: options.mr_iids.clone(),
|
||||
}),
|
||||
..SyncResult::default()
|
||||
};
|
||||
let mut entity_results: Vec<EntitySyncResult> = Vec::new();
|
||||
|
||||
// ── Resolve project ──
|
||||
let project_str = options.project.as_deref().ok_or_else(|| {
|
||||
LoreError::Other(
|
||||
"Surgical sync requires --project. Specify the project path.".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let schema_version = get_schema_version(&conn);
|
||||
if schema_version < LATEST_SCHEMA_VERSION {
|
||||
return Err(LoreError::MigrationFailed {
|
||||
version: schema_version,
|
||||
message: format!(
|
||||
"Database is at schema version {schema_version} but {LATEST_SCHEMA_VERSION} is required. \
|
||||
Run 'lore sync' first to apply migrations."
|
||||
),
|
||||
source: None,
|
||||
});
|
||||
}
|
||||
|
||||
let project_id = resolve_project(&conn, project_str)?;
|
||||
|
||||
let gitlab_project_id: i64 = conn.query_row(
|
||||
"SELECT gitlab_project_id FROM projects WHERE id = ?1",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
debug!(
|
||||
project_str,
|
||||
project_id,
|
||||
gitlab_project_id,
|
||||
"Resolved project for surgical sync"
|
||||
);
|
||||
|
||||
// ── Start recorder ──
|
||||
let recorder_conn = create_connection(&db_path)?;
|
||||
let recorder = SyncRunRecorder::start(&recorder_conn, "surgical-sync", run_id)?;
|
||||
|
||||
let iids_json = serde_json::to_string(&SurgicalIids {
|
||||
issues: options.issue_iids.clone(),
|
||||
merge_requests: options.mr_iids.clone(),
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
recorder.set_surgical_metadata(&recorder_conn, "surgical", "preflight", &iids_json)?;
|
||||
|
||||
// Wrap recorder in Option for consuming terminal methods
|
||||
let mut recorder = Some(recorder);
|
||||
|
||||
// ── Build GitLab client ──
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
let client = GitLabClient::new(
|
||||
&config.gitlab.base_url,
|
||||
&token,
|
||||
Some(config.sync.requests_per_second),
|
||||
);
|
||||
|
||||
// ── Build targets list ──
|
||||
let mut targets: Vec<(String, i64)> = Vec::new();
|
||||
for iid in &options.issue_iids {
|
||||
targets.push(("issue".to_string(), *iid as i64));
|
||||
}
|
||||
for iid in &options.mr_iids {
|
||||
targets.push(("merge_request".to_string(), *iid as i64));
|
||||
}
|
||||
|
||||
// ── Stage: Preflight ──
|
||||
let stage_start = Instant::now();
|
||||
let spinner =
|
||||
stage_spinner_v2(Icons::sync(), "Preflight", "fetching...", options.robot_mode);
|
||||
|
||||
info!(targets = targets.len(), "Preflight: fetching entities from GitLab");
|
||||
let preflight = preflight_fetch(&client, gitlab_project_id, &targets).await;
|
||||
|
||||
// Record preflight failures
|
||||
for failure in &preflight.failures {
|
||||
let is_not_found = matches!(&failure.error, LoreError::GitLabNotFound { .. });
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: failure.entity_type.clone(),
|
||||
iid: failure.iid as u64,
|
||||
outcome: if is_not_found {
|
||||
"not_found".to_string()
|
||||
} else {
|
||||
"preflight_failed".to_string()
|
||||
},
|
||||
error: Some(failure.error.to_string()),
|
||||
toctou_reason: None,
|
||||
});
|
||||
if let Some(ref rec) = recorder {
|
||||
let _ = rec.record_entity_result(&recorder_conn, &failure.entity_type, "warning");
|
||||
}
|
||||
}
|
||||
|
||||
let preflight_summary = format!(
|
||||
"{} issues, {} MRs fetched ({} failed)",
|
||||
preflight.issues.len(),
|
||||
preflight.merge_requests.len(),
|
||||
preflight.failures.len()
|
||||
);
|
||||
let preflight_icon = color_icon(
|
||||
if preflight.failures.is_empty() {
|
||||
Icons::success()
|
||||
} else {
|
||||
Icons::warning()
|
||||
},
|
||||
!preflight.failures.is_empty(),
|
||||
);
|
||||
emit_stage_line(
|
||||
&spinner,
|
||||
&preflight_icon,
|
||||
"Preflight",
|
||||
&preflight_summary,
|
||||
stage_start.elapsed(),
|
||||
options.robot_mode,
|
||||
);
|
||||
|
||||
// ── Preflight-only early return ──
|
||||
if options.preflight_only {
|
||||
result.preflight_only = Some(true);
|
||||
result.entity_results = Some(entity_results);
|
||||
if let Some(rec) = recorder.take() {
|
||||
rec.succeed(&recorder_conn, &[], 0, preflight.failures.len())?;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Check cancellation ──
|
||||
if signal.is_cancelled() {
|
||||
if let Some(rec) = recorder.take() {
|
||||
rec.cancel(&recorder_conn, "cancelled before ingest")?;
|
||||
}
|
||||
result.entity_results = Some(entity_results);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Acquire lock ──
|
||||
let lock_conn = create_connection(&db_path)?;
|
||||
let mut lock = AppLock::new(
|
||||
lock_conn,
|
||||
LockOptions {
|
||||
name: "sync".to_string(),
|
||||
stale_lock_minutes: config.sync.stale_lock_minutes,
|
||||
heartbeat_interval_seconds: config.sync.heartbeat_interval_seconds,
|
||||
},
|
||||
);
|
||||
lock.acquire(options.force)?;
|
||||
|
||||
// Wrap the rest in a closure-like block to ensure lock release on error
|
||||
let pipeline_result = run_pipeline_stages(
|
||||
&conn,
|
||||
&recorder_conn,
|
||||
config,
|
||||
&client,
|
||||
&options,
|
||||
&preflight,
|
||||
project_id,
|
||||
gitlab_project_id,
|
||||
&mut entity_results,
|
||||
&mut result,
|
||||
recorder.as_ref(),
|
||||
signal,
|
||||
)
|
||||
.await;
|
||||
|
||||
match pipeline_result {
|
||||
Ok(()) => {
|
||||
// ── Finalize: succeed ──
|
||||
if let Some(ref rec) = recorder {
|
||||
let _ = rec.update_phase(&recorder_conn, "finalize");
|
||||
}
|
||||
let total_items = result.issues_updated
|
||||
+ result.mrs_updated
|
||||
+ result.documents_regenerated
|
||||
+ result.documents_embedded;
|
||||
let total_errors = result.documents_errored
|
||||
+ result.embedding_failed
|
||||
+ entity_results
|
||||
.iter()
|
||||
.filter(|e| e.outcome != "synced" && e.outcome != "skipped_stale")
|
||||
.count();
|
||||
if let Some(rec) = recorder.take() {
|
||||
rec.succeed(&recorder_conn, &[], total_items, total_errors)?;
|
||||
}
|
||||
}
|
||||
Err(ref e) => {
|
||||
if let Some(rec) = recorder.take() {
|
||||
let _ = rec.fail(&recorder_conn, &e.to_string(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lock.release();
|
||||
|
||||
// Propagate error after cleanup
|
||||
pipeline_result?;
|
||||
|
||||
result.entity_results = Some(entity_results);
|
||||
|
||||
let elapsed = pipeline_start.elapsed();
|
||||
debug!(
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
issues = result.issues_updated,
|
||||
mrs = result.mrs_updated,
|
||||
docs = result.documents_regenerated,
|
||||
embedded = result.documents_embedded,
|
||||
"Surgical sync pipeline complete"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_pipeline_stages(
|
||||
conn: &rusqlite::Connection,
|
||||
recorder_conn: &rusqlite::Connection,
|
||||
config: &Config,
|
||||
client: &GitLabClient,
|
||||
options: &SyncOptions,
|
||||
preflight: &crate::ingestion::surgical::PreflightResult,
|
||||
project_id: i64,
|
||||
gitlab_project_id: i64,
|
||||
entity_results: &mut Vec<EntitySyncResult>,
|
||||
result: &mut SyncResult,
|
||||
recorder: Option<&SyncRunRecorder>,
|
||||
signal: &ShutdownSignal,
|
||||
) -> Result<()> {
|
||||
let mut all_dirty_source_keys: Vec<(SourceType, i64)> = Vec::new();
|
||||
|
||||
// ── Stage: Ingest ──
|
||||
if let Some(rec) = recorder {
|
||||
rec.update_phase(recorder_conn, "ingest")?;
|
||||
}
|
||||
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Ingest", "processing...", options.robot_mode);
|
||||
|
||||
// Ingest issues
|
||||
for issue in &preflight.issues {
|
||||
match ingest_issue_by_iid(conn, config, project_id, issue) {
|
||||
Ok(ingest_result) => {
|
||||
if ingest_result.skipped_stale {
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: "issue".to_string(),
|
||||
iid: issue.iid as u64,
|
||||
outcome: "skipped_stale".to_string(),
|
||||
error: None,
|
||||
toctou_reason: Some("updated_at not newer than DB".to_string()),
|
||||
});
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "issue", "skipped_stale");
|
||||
}
|
||||
} else {
|
||||
result.issues_updated += 1;
|
||||
all_dirty_source_keys.extend(ingest_result.dirty_source_keys);
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: "issue".to_string(),
|
||||
iid: issue.iid as u64,
|
||||
outcome: "synced".to_string(),
|
||||
error: None,
|
||||
toctou_reason: None,
|
||||
});
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "issue", "ingested");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(iid = issue.iid, error = %e, "Failed to ingest issue");
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: "issue".to_string(),
|
||||
iid: issue.iid as u64,
|
||||
outcome: "error".to_string(),
|
||||
error: Some(e.to_string()),
|
||||
toctou_reason: None,
|
||||
});
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "issue", "warning");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ingest MRs
|
||||
for mr in &preflight.merge_requests {
|
||||
match ingest_mr_by_iid(conn, config, project_id, mr) {
|
||||
Ok(ingest_result) => {
|
||||
if ingest_result.skipped_stale {
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: "merge_request".to_string(),
|
||||
iid: mr.iid as u64,
|
||||
outcome: "skipped_stale".to_string(),
|
||||
error: None,
|
||||
toctou_reason: Some("updated_at not newer than DB".to_string()),
|
||||
});
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "mr", "skipped_stale");
|
||||
}
|
||||
} else {
|
||||
result.mrs_updated += 1;
|
||||
all_dirty_source_keys.extend(ingest_result.dirty_source_keys);
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: "merge_request".to_string(),
|
||||
iid: mr.iid as u64,
|
||||
outcome: "synced".to_string(),
|
||||
error: None,
|
||||
toctou_reason: None,
|
||||
});
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "mr", "ingested");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(iid = mr.iid, error = %e, "Failed to ingest MR");
|
||||
entity_results.push(EntitySyncResult {
|
||||
entity_type: "merge_request".to_string(),
|
||||
iid: mr.iid as u64,
|
||||
outcome: "error".to_string(),
|
||||
error: Some(e.to_string()),
|
||||
toctou_reason: None,
|
||||
});
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "mr", "warning");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ingest_summary = format!(
|
||||
"{} issues, {} MRs ingested",
|
||||
result.issues_updated, result.mrs_updated
|
||||
);
|
||||
let ingest_icon = color_icon(Icons::success(), false);
|
||||
emit_stage_line(
|
||||
&spinner,
|
||||
&ingest_icon,
|
||||
"Ingest",
|
||||
&ingest_summary,
|
||||
stage_start.elapsed(),
|
||||
options.robot_mode,
|
||||
);
|
||||
|
||||
// ── Check cancellation ──
|
||||
if signal.is_cancelled() {
|
||||
debug!("Shutdown requested after ingest stage");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Stage: Dependents ──
|
||||
if let Some(rec) = recorder {
|
||||
rec.update_phase(recorder_conn, "dependents")?;
|
||||
}
|
||||
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::sync(),
|
||||
"Dependents",
|
||||
"fetching...",
|
||||
options.robot_mode,
|
||||
);
|
||||
|
||||
let mut total_discussions: usize = 0;
|
||||
let mut total_events: usize = 0;
|
||||
|
||||
// Fetch dependents for successfully ingested issues
|
||||
for issue in &preflight.issues {
|
||||
// Only fetch dependents for entities that were actually ingested
|
||||
let was_ingested = entity_results.iter().any(|e| {
|
||||
e.entity_type == "issue" && e.iid == issue.iid as u64 && e.outcome == "synced"
|
||||
});
|
||||
if !was_ingested {
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_id: i64 = match conn.query_row(
|
||||
"SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2",
|
||||
(project_id, issue.iid),
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
warn!(iid = issue.iid, error = %e, "Could not find local issue ID for dependents");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match fetch_dependents_for_issue(
|
||||
client,
|
||||
conn,
|
||||
project_id,
|
||||
gitlab_project_id,
|
||||
issue.iid,
|
||||
local_id,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dep_result) => {
|
||||
total_discussions += dep_result.discussions_fetched;
|
||||
total_events += dep_result.resource_events_fetched;
|
||||
result.discussions_fetched += dep_result.discussions_fetched;
|
||||
result.resource_events_fetched += dep_result.resource_events_fetched;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(iid = issue.iid, error = %e, "Failed to fetch dependents for issue");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch dependents for successfully ingested MRs
|
||||
for mr in &preflight.merge_requests {
|
||||
let was_ingested = entity_results.iter().any(|e| {
|
||||
e.entity_type == "merge_request" && e.iid == mr.iid as u64 && e.outcome == "synced"
|
||||
});
|
||||
if !was_ingested {
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_id: i64 = match conn.query_row(
|
||||
"SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2",
|
||||
(project_id, mr.iid),
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
warn!(iid = mr.iid, error = %e, "Could not find local MR ID for dependents");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match fetch_dependents_for_mr(
|
||||
client,
|
||||
conn,
|
||||
project_id,
|
||||
gitlab_project_id,
|
||||
mr.iid,
|
||||
local_id,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dep_result) => {
|
||||
total_discussions += dep_result.discussions_fetched;
|
||||
total_events += dep_result.resource_events_fetched;
|
||||
result.discussions_fetched += dep_result.discussions_fetched;
|
||||
result.resource_events_fetched += dep_result.resource_events_fetched;
|
||||
result.mr_diffs_fetched += dep_result.file_changes_stored;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(iid = mr.iid, error = %e, "Failed to fetch dependents for MR");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dep_summary = format!("{} discussions, {} events", total_discussions, total_events);
|
||||
let dep_icon = color_icon(Icons::success(), false);
|
||||
emit_stage_line(
|
||||
&spinner,
|
||||
&dep_icon,
|
||||
"Dependents",
|
||||
&dep_summary,
|
||||
stage_start.elapsed(),
|
||||
options.robot_mode,
|
||||
);
|
||||
|
||||
// ── Check cancellation ──
|
||||
if signal.is_cancelled() {
|
||||
debug!("Shutdown requested after dependents stage");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Stage: Docs ──
|
||||
if !options.no_docs && !all_dirty_source_keys.is_empty() {
|
||||
if let Some(rec) = recorder {
|
||||
rec.update_phase(recorder_conn, "docs")?;
|
||||
}
|
||||
|
||||
let stage_start = Instant::now();
|
||||
let spinner =
|
||||
stage_spinner_v2(Icons::sync(), "Docs", "regenerating...", options.robot_mode);
|
||||
|
||||
let docs_result = regenerate_dirty_documents_for_sources(conn, &all_dirty_source_keys)?;
|
||||
result.documents_regenerated = docs_result.regenerated;
|
||||
result.documents_errored = docs_result.errored;
|
||||
|
||||
for _ in 0..docs_result.regenerated {
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "doc", "regenerated");
|
||||
}
|
||||
}
|
||||
|
||||
let docs_summary = format!("{} documents regenerated", result.documents_regenerated);
|
||||
let docs_icon = color_icon(
|
||||
if docs_result.errored > 0 {
|
||||
Icons::warning()
|
||||
} else {
|
||||
Icons::success()
|
||||
},
|
||||
docs_result.errored > 0,
|
||||
);
|
||||
emit_stage_line(
|
||||
&spinner,
|
||||
&docs_icon,
|
||||
"Docs",
|
||||
&docs_summary,
|
||||
stage_start.elapsed(),
|
||||
options.robot_mode,
|
||||
);
|
||||
|
||||
// ── Check cancellation ──
|
||||
if signal.is_cancelled() {
|
||||
debug!("Shutdown requested after docs stage");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Stage: Embed ──
|
||||
if !options.no_embed && !docs_result.document_ids.is_empty() {
|
||||
if let Some(rec) = recorder {
|
||||
rec.update_phase(recorder_conn, "embed")?;
|
||||
}
|
||||
|
||||
let stage_start = Instant::now();
|
||||
let spinner =
|
||||
stage_spinner_v2(Icons::sync(), "Embed", "embedding...", options.robot_mode);
|
||||
|
||||
let ollama_config = OllamaConfig {
|
||||
base_url: config.embedding.base_url.clone(),
|
||||
model: config.embedding.model.clone(),
|
||||
..OllamaConfig::default()
|
||||
};
|
||||
let ollama_client = OllamaClient::new(ollama_config);
|
||||
|
||||
let model_name = &config.embedding.model;
|
||||
let concurrency = if config.embedding.concurrency > 0 {
|
||||
config.embedding.concurrency as usize
|
||||
} else {
|
||||
DEFAULT_EMBED_CONCURRENCY
|
||||
};
|
||||
|
||||
match embed_documents_by_ids(
|
||||
conn,
|
||||
&ollama_client,
|
||||
model_name,
|
||||
concurrency,
|
||||
&docs_result.document_ids,
|
||||
signal,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(embed_result) => {
|
||||
result.documents_embedded = embed_result.docs_embedded;
|
||||
result.embedding_failed = embed_result.failed;
|
||||
|
||||
for _ in 0..embed_result.docs_embedded {
|
||||
if let Some(rec) = recorder {
|
||||
let _ = rec.record_entity_result(recorder_conn, "doc", "embedded");
|
||||
}
|
||||
}
|
||||
|
||||
let embed_summary = format!("{} chunks embedded", embed_result.chunks_embedded);
|
||||
let embed_icon = color_icon(
|
||||
if embed_result.failed > 0 {
|
||||
Icons::warning()
|
||||
} else {
|
||||
Icons::success()
|
||||
},
|
||||
embed_result.failed > 0,
|
||||
);
|
||||
emit_stage_line(
|
||||
&spinner,
|
||||
&embed_icon,
|
||||
"Embed",
|
||||
&embed_summary,
|
||||
stage_start.elapsed(),
|
||||
options.robot_mode,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let warn_summary = format!("skipped ({})", e);
|
||||
let warn_icon = color_icon(Icons::warning(), true);
|
||||
emit_stage_line(
|
||||
&spinner,
|
||||
&warn_icon,
|
||||
"Embed",
|
||||
&warn_summary,
|
||||
stage_start.elapsed(),
|
||||
options.robot_mode,
|
||||
);
|
||||
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Alias for [`Theme::color_icon`] to keep call sites concise.
|
||||
fn color_icon(icon: &str, has_errors: bool) -> String {
|
||||
Theme::color_icon(icon, has_errors)
|
||||
}
|
||||
|
||||
fn emit_stage_line(
|
||||
pb: &indicatif::ProgressBar,
|
||||
icon: &str,
|
||||
label: &str,
|
||||
summary: &str,
|
||||
elapsed: std::time::Duration,
|
||||
robot_mode: bool,
|
||||
) {
|
||||
pb.finish_and_clear();
|
||||
if !robot_mode {
|
||||
crate::cli::progress::multi().suspend(|| {
|
||||
println!("{}", format_stage_line(icon, label, summary, elapsed));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::cli::commands::sync::SyncOptions;
|
||||
|
||||
#[test]
|
||||
fn sync_options_is_surgical_required() {
|
||||
let opts = SyncOptions {
|
||||
issue_iids: vec![1],
|
||||
project: Some("group/repo".to_string()),
|
||||
..SyncOptions::default()
|
||||
};
|
||||
assert!(opts.is_surgical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_options_surgical_with_mrs() {
|
||||
let opts = SyncOptions {
|
||||
mr_iids: vec![10, 20],
|
||||
project: Some("group/repo".to_string()),
|
||||
..SyncOptions::default()
|
||||
};
|
||||
assert!(opts.is_surgical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_options_not_surgical_without_iids() {
|
||||
let opts = SyncOptions {
|
||||
project: Some("group/repo".to_string()),
|
||||
..SyncOptions::default()
|
||||
};
|
||||
assert!(!opts.is_surgical());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use console::{Alignment, pad_str, style};
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::progress::stage_spinner_v2;
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::get_db_path;
|
||||
@@ -12,7 +13,8 @@ use crate::core::timeline::{
|
||||
};
|
||||
use crate::core::timeline_collect::collect_events;
|
||||
use crate::core::timeline_expand::expand_timeline;
|
||||
use crate::core::timeline_seed::seed_timeline;
|
||||
use crate::core::timeline_seed::{seed_timeline, seed_timeline_direct};
|
||||
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||
|
||||
/// Parameters for running the timeline pipeline.
|
||||
pub struct TimelineParams {
|
||||
@@ -20,15 +22,53 @@ pub struct TimelineParams {
|
||||
pub project: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub depth: u32,
|
||||
pub expand_mentions: bool,
|
||||
pub no_mentions: bool,
|
||||
pub limit: usize,
|
||||
pub max_seeds: usize,
|
||||
pub max_entities: usize,
|
||||
pub max_evidence: usize,
|
||||
pub robot_mode: bool,
|
||||
}
|
||||
|
||||
/// Parsed timeline query: either a search string or a direct entity reference.
|
||||
enum TimelineQuery {
|
||||
Search(String),
|
||||
EntityDirect { entity_type: String, iid: i64 },
|
||||
}
|
||||
|
||||
/// Parse the timeline query for entity-direct patterns.
|
||||
///
|
||||
/// Recognized patterns (case-insensitive prefix):
|
||||
/// - `issue:N`, `i:N` -> issue
|
||||
/// - `mr:N`, `m:N` -> merge_request
|
||||
/// - Anything else -> search query
|
||||
fn parse_timeline_query(query: &str) -> TimelineQuery {
|
||||
let query = query.trim();
|
||||
if let Some((prefix, rest)) = query.split_once(':') {
|
||||
let prefix_lower = prefix.to_ascii_lowercase();
|
||||
if let Ok(iid) = rest.trim().parse::<i64>() {
|
||||
match prefix_lower.as_str() {
|
||||
"issue" | "i" => {
|
||||
return TimelineQuery::EntityDirect {
|
||||
entity_type: "issue".to_owned(),
|
||||
iid,
|
||||
};
|
||||
}
|
||||
"mr" | "m" => {
|
||||
return TimelineQuery::EntityDirect {
|
||||
entity_type: "merge_request".to_owned(),
|
||||
iid,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
TimelineQuery::Search(query.to_owned())
|
||||
}
|
||||
|
||||
/// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT.
|
||||
pub fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
||||
pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
@@ -50,39 +90,92 @@ pub fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Timeline
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Stage 1+2: SEED + HYDRATE
|
||||
let seed_result = seed_timeline(
|
||||
&conn,
|
||||
¶ms.query,
|
||||
project_id,
|
||||
since_ms,
|
||||
params.max_seeds,
|
||||
params.max_evidence,
|
||||
)?;
|
||||
// Parse query for entity-direct syntax (issue:N, mr:N, i:N, m:N)
|
||||
let parsed_query = parse_timeline_query(¶ms.query);
|
||||
|
||||
let seed_result = match parsed_query {
|
||||
TimelineQuery::EntityDirect { entity_type, iid } => {
|
||||
// Direct seeding: synchronous, no Ollama needed
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::search(),
|
||||
"Resolve",
|
||||
"Resolving entity...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?;
|
||||
spinner.finish_and_clear();
|
||||
result
|
||||
}
|
||||
TimelineQuery::Search(ref query) => {
|
||||
// Construct OllamaClient for hybrid search (same pattern as run_search)
|
||||
let ollama_cfg = &config.embedding;
|
||||
let client = OllamaClient::new(OllamaConfig {
|
||||
base_url: ollama_cfg.base_url.clone(),
|
||||
model: ollama_cfg.model.clone(),
|
||||
..OllamaConfig::default()
|
||||
});
|
||||
|
||||
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::search(),
|
||||
"Seed",
|
||||
"Seeding timeline...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let result = seed_timeline(
|
||||
&conn,
|
||||
Some(&client),
|
||||
query,
|
||||
project_id,
|
||||
since_ms,
|
||||
params.max_seeds,
|
||||
params.max_evidence,
|
||||
)
|
||||
.await?;
|
||||
spinner.finish_and_clear();
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: EXPAND
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::sync(),
|
||||
"Expand",
|
||||
"Expanding cross-references...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let expand_result = expand_timeline(
|
||||
&conn,
|
||||
&seed_result.seed_entities,
|
||||
params.depth,
|
||||
params.expand_mentions,
|
||||
!params.no_mentions,
|
||||
params.max_entities,
|
||||
)?;
|
||||
spinner.finish_and_clear();
|
||||
|
||||
// Stage 4: COLLECT
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::sync(),
|
||||
"Collect",
|
||||
"Collecting events...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let (events, total_before_limit) = collect_events(
|
||||
&conn,
|
||||
&seed_result.seed_entities,
|
||||
&expand_result.expanded_entities,
|
||||
&seed_result.evidence_notes,
|
||||
&seed_result.matched_discussions,
|
||||
since_ms,
|
||||
params.limit,
|
||||
)?;
|
||||
spinner.finish_and_clear();
|
||||
|
||||
Ok(TimelineResult {
|
||||
query: params.query.clone(),
|
||||
search_mode: seed_result.search_mode,
|
||||
events,
|
||||
total_events_before_limit: total_before_limit,
|
||||
total_filtered_events: total_before_limit,
|
||||
seed_entities: seed_result.seed_entities,
|
||||
expanded_entities: expand_result.expanded_entities,
|
||||
unresolved_references: expand_result.unresolved_references,
|
||||
@@ -98,19 +191,21 @@ pub fn print_timeline(result: &TimelineResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::bold().render(&format!(
|
||||
"Timeline: \"{}\" ({} events across {} entities)",
|
||||
result.query,
|
||||
result.events.len(),
|
||||
entity_count,
|
||||
))
|
||||
.bold()
|
||||
);
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!();
|
||||
|
||||
if result.events.is_empty() {
|
||||
println!(" {}", style("No events found for this query.").dim());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No events found for this query.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
@@ -120,13 +215,18 @@ pub fn print_timeline(result: &TimelineResult) {
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
print_timeline_footer(result);
|
||||
}
|
||||
|
||||
fn print_timeline_event(event: &TimelineEvent) {
|
||||
let date = format_date(event.timestamp);
|
||||
let date = render::format_date(event.timestamp);
|
||||
let tag = format_event_tag(&event.event_type);
|
||||
let entity_icon = match event.entity_type.as_str() {
|
||||
"issue" => Icons::issue_opened(),
|
||||
"merge_request" => Icons::mr_opened(),
|
||||
_ => "",
|
||||
};
|
||||
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
||||
let actor = event
|
||||
.actor
|
||||
@@ -135,21 +235,41 @@ fn print_timeline_event(event: &TimelineEvent) {
|
||||
.unwrap_or_default();
|
||||
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
||||
|
||||
let summary = truncate_summary(&event.summary, 50);
|
||||
let tag_padded = pad_str(&tag, 12, Alignment::Left, None);
|
||||
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||
let summary = render::truncate(&event.summary, 50);
|
||||
println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||
|
||||
// Show snippet for evidence notes
|
||||
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
||||
&& !snippet.is_empty()
|
||||
{
|
||||
for line in wrap_snippet(snippet, 60) {
|
||||
let mut lines = render::wrap_lines(snippet, 60);
|
||||
lines.truncate(4);
|
||||
for line in lines {
|
||||
println!(
|
||||
" \"{}\"",
|
||||
style(line).dim()
|
||||
Theme::dim().render(&line)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show full discussion thread
|
||||
if let TimelineEventType::DiscussionThread { notes, .. } = &event.event_type {
|
||||
let bar = "\u{2500}".repeat(44);
|
||||
println!(" \u{2500}\u{2500} Discussion {bar}");
|
||||
for note in notes {
|
||||
let note_date = render::format_date(note.created_at);
|
||||
let author = note
|
||||
.author
|
||||
.as_deref()
|
||||
.map(|a| format!("@{a}"))
|
||||
.unwrap_or_else(|| "unknown".to_owned());
|
||||
println!(" {} ({note_date}):", Theme::bold().render(&author));
|
||||
for line in render::wrap_lines(¬e.body, 60) {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
println!(" {}", "\u{2500}".repeat(60));
|
||||
}
|
||||
}
|
||||
|
||||
fn print_timeline_footer(result: &TimelineResult) {
|
||||
@@ -180,22 +300,33 @@ fn print_timeline_footer(result: &TimelineResult) {
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Format event tag: pad plain text to TAG_WIDTH, then apply style.
|
||||
const TAG_WIDTH: usize = 11;
|
||||
|
||||
fn format_event_tag(event_type: &TimelineEventType) -> String {
|
||||
match event_type {
|
||||
TimelineEventType::Created => style("CREATED").green().to_string(),
|
||||
let (label, style) = match event_type {
|
||||
TimelineEventType::Created => ("CREATED", Theme::success()),
|
||||
TimelineEventType::StateChanged { state } => match state.as_str() {
|
||||
"closed" => style("CLOSED").red().to_string(),
|
||||
"reopened" => style("REOPENED").yellow().to_string(),
|
||||
_ => style(state.to_uppercase()).dim().to_string(),
|
||||
"closed" => ("CLOSED", Theme::error()),
|
||||
"reopened" => ("REOPENED", Theme::warning()),
|
||||
_ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()),
|
||||
},
|
||||
TimelineEventType::LabelAdded { .. } => style("LABEL+").blue().to_string(),
|
||||
TimelineEventType::LabelRemoved { .. } => style("LABEL-").blue().to_string(),
|
||||
TimelineEventType::MilestoneSet { .. } => style("MILESTONE+").magenta().to_string(),
|
||||
TimelineEventType::MilestoneRemoved { .. } => style("MILESTONE-").magenta().to_string(),
|
||||
TimelineEventType::Merged => style("MERGED").cyan().to_string(),
|
||||
TimelineEventType::NoteEvidence { .. } => style("NOTE").dim().to_string(),
|
||||
TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(),
|
||||
}
|
||||
TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()),
|
||||
TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()),
|
||||
TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()),
|
||||
TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()),
|
||||
TimelineEventType::Merged => ("MERGED", Theme::info()),
|
||||
TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()),
|
||||
TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()),
|
||||
TimelineEventType::CrossReferenced { .. } => ("REF", Theme::dim()),
|
||||
};
|
||||
style_padded(label, TAG_WIDTH, style)
|
||||
}
|
||||
|
||||
/// Pad text to width, then apply lipgloss style (so ANSI codes don't break alignment).
|
||||
fn style_padded(text: &str, width: usize, style: lipgloss::Style) -> String {
|
||||
let padded = format!("{:<width$}", text);
|
||||
style.render(&padded)
|
||||
}
|
||||
|
||||
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||
@@ -206,64 +337,27 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_date(ms: i64) -> String {
|
||||
let iso = ms_to_iso(ms);
|
||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
||||
}
|
||||
|
||||
fn truncate_summary(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_owned()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max - 3).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_snippet(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
if current.is_empty() {
|
||||
current = word.to_string();
|
||||
} else if current.len() + 1 + word.len() <= width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
lines.push(current);
|
||||
current = word.to_string();
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
// Cap at 4 lines
|
||||
lines.truncate(4);
|
||||
lines
|
||||
}
|
||||
|
||||
// ─── Robot JSON output ───────────────────────────────────────────────────────
|
||||
|
||||
/// Render timeline as robot-mode JSON in {ok, data, meta} envelope.
|
||||
pub fn print_timeline_json_with_meta(
|
||||
result: &TimelineResult,
|
||||
total_events_before_limit: usize,
|
||||
total_filtered_events: usize,
|
||||
depth: u32,
|
||||
expand_mentions: bool,
|
||||
include_mentions: bool,
|
||||
fields: Option<&[String]>,
|
||||
) {
|
||||
let output = TimelineJsonEnvelope {
|
||||
ok: true,
|
||||
data: TimelineDataJson::from_result(result),
|
||||
meta: TimelineMetaJson {
|
||||
search_mode: "lexical".to_owned(),
|
||||
search_mode: result.search_mode.clone(),
|
||||
expansion_depth: depth,
|
||||
expand_mentions,
|
||||
include_mentions,
|
||||
total_entities: result.seed_entities.len() + result.expanded_entities.len(),
|
||||
total_events: total_events_before_limit,
|
||||
total_events: total_filtered_events,
|
||||
evidence_notes_included: count_evidence_notes(&result.events),
|
||||
discussion_threads_included: count_discussion_threads(&result.events),
|
||||
unresolved_references: result.unresolved_references.len(),
|
||||
showing: result.events.len(),
|
||||
},
|
||||
@@ -280,7 +374,10 @@ pub fn print_timeline_json_with_meta(
|
||||
let expanded = crate::cli::robot::expand_fields_preset(f, "timeline");
|
||||
crate::cli::robot::filter_fields(&mut value, "events", &expanded);
|
||||
}
|
||||
println!("{}", serde_json::to_string(&value).unwrap());
|
||||
match serde_json::to_string(&value) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -461,6 +558,22 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va
|
||||
"discussion_id": discussion_id,
|
||||
}),
|
||||
),
|
||||
TimelineEventType::DiscussionThread {
|
||||
discussion_id,
|
||||
notes,
|
||||
} => (
|
||||
"discussion_thread".to_owned(),
|
||||
serde_json::json!({
|
||||
"discussion_id": discussion_id,
|
||||
"note_count": notes.len(),
|
||||
"notes": notes.iter().map(|n| serde_json::json!({
|
||||
"note_id": n.note_id,
|
||||
"author": n.author,
|
||||
"body": n.body,
|
||||
"created_at": ms_to_iso(n.created_at),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
),
|
||||
TimelineEventType::CrossReferenced { target } => (
|
||||
"cross_referenced".to_owned(),
|
||||
serde_json::json!({ "target": target }),
|
||||
@@ -472,10 +585,11 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va
|
||||
struct TimelineMetaJson {
|
||||
search_mode: String,
|
||||
expansion_depth: u32,
|
||||
expand_mentions: bool,
|
||||
include_mentions: bool,
|
||||
total_entities: usize,
|
||||
total_events: usize,
|
||||
evidence_notes_included: usize,
|
||||
discussion_threads_included: usize,
|
||||
unresolved_references: usize,
|
||||
showing: usize,
|
||||
}
|
||||
@@ -486,3 +600,91 @@ fn count_evidence_notes(events: &[TimelineEvent]) -> usize {
|
||||
.filter(|e| matches!(e.event_type, TimelineEventType::NoteEvidence { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn count_discussion_threads(events: &[TimelineEvent]) -> usize {
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event_type, TimelineEventType::DiscussionThread { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_issue_colon_number() {
|
||||
let q = parse_timeline_query("issue:42");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_i_colon_number() {
|
||||
let q = parse_timeline_query("i:42");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mr_colon_number() {
|
||||
let q = parse_timeline_query("mr:99");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_m_colon_number() {
|
||||
let q = parse_timeline_query("m:99");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_case_insensitive() {
|
||||
let q = parse_timeline_query("ISSUE:42");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||
);
|
||||
|
||||
let q = parse_timeline_query("MR:99");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99)
|
||||
);
|
||||
|
||||
let q = parse_timeline_query("Issue:7");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_search_fallback() {
|
||||
let q = parse_timeline_query("switch health");
|
||||
assert!(matches!(q, TimelineQuery::Search(ref s) if s == "switch health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_non_numeric_falls_back_to_search() {
|
||||
let q = parse_timeline_query("issue:abc");
|
||||
assert!(matches!(q, TimelineQuery::Search(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unknown_prefix_falls_back_to_search() {
|
||||
let q = parse_timeline_query("foo:42");
|
||||
assert!(matches!(q, TimelineQuery::Search(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_whitespace_trimmed() {
|
||||
let q = parse_timeline_query(" issue:42 ");
|
||||
assert!(
|
||||
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
249
src/cli/commands/trace.rs
Normal file
249
src/cli/commands/trace.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::core::trace::{TraceChain, TraceResult};
|
||||
|
||||
/// Parse a path with optional `:line` suffix.
|
||||
///
|
||||
/// Handles Windows drive letters (e.g. `C:/foo.rs`) by checking that the
|
||||
/// prefix before the colon is not a single ASCII letter.
|
||||
pub fn parse_trace_path(input: &str) -> (String, Option<u32>) {
|
||||
if let Some((path, suffix)) = input.rsplit_once(':')
|
||||
&& !path.is_empty()
|
||||
&& let Ok(line) = suffix.parse::<u32>()
|
||||
// Reject Windows drive letters: single ASCII letter before colon
|
||||
&& (path.len() > 1 || !path.chars().next().unwrap_or(' ').is_ascii_alphabetic())
|
||||
{
|
||||
return (path.to_string(), Some(line));
|
||||
}
|
||||
(input.to_string(), None)
|
||||
}
|
||||
|
||||
// ── Human output ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_trace(result: &TraceResult) {
|
||||
let chain_info = if result.total_chains == 1 {
|
||||
"1 chain".to_string()
|
||||
} else {
|
||||
format!("{} chains", result.total_chains)
|
||||
};
|
||||
|
||||
let paths_info = if result.resolved_paths.len() > 1 {
|
||||
format!(", {} paths", result.resolved_paths.len())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!(
|
||||
"Trace: {} ({}{})",
|
||||
result.path, chain_info, paths_info
|
||||
))
|
||||
);
|
||||
|
||||
// Rename chain
|
||||
if result.renames_followed && result.resolved_paths.len() > 1 {
|
||||
let chain_str: Vec<&str> = result.resolved_paths.iter().map(String::as_str).collect();
|
||||
println!(
|
||||
" Rename chain: {}",
|
||||
Theme::dim().render(&chain_str.join(" -> "))
|
||||
);
|
||||
}
|
||||
|
||||
// Show searched paths when there are renames but no chains
|
||||
if result.trace_chains.is_empty() {
|
||||
println!(
|
||||
"\n {} {}",
|
||||
Icons::info(),
|
||||
Theme::dim().render("No trace chains found for this file.")
|
||||
);
|
||||
if !result.renames_followed && result.resolved_paths.len() == 1 {
|
||||
println!(
|
||||
" {} Searched: {}",
|
||||
Icons::info(),
|
||||
Theme::dim().render(&result.resolved_paths[0])
|
||||
);
|
||||
}
|
||||
for hint in &result.hints {
|
||||
println!(" {} {}", Icons::info(), Theme::dim().render(hint));
|
||||
}
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
for chain in &result.trace_chains {
|
||||
print_chain(chain);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_chain(chain: &TraceChain) {
|
||||
let (icon, state_style) = match chain.mr_state.as_str() {
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"closed" => (Icons::mr_closed(), Theme::warning()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
|
||||
let date = chain
|
||||
.merged_at_iso
|
||||
.as_deref()
|
||||
.or(Some(chain.updated_at_iso.as_str()))
|
||||
.unwrap_or("")
|
||||
.split('T')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
println!(
|
||||
" {} {} {} {} @{} {} {}",
|
||||
icon,
|
||||
Theme::accent().render(&format!("!{}", chain.mr_iid)),
|
||||
chain.mr_title,
|
||||
state_style.render(&chain.mr_state),
|
||||
chain.mr_author,
|
||||
date,
|
||||
Theme::dim().render(&chain.change_type),
|
||||
);
|
||||
|
||||
// Linked issues
|
||||
for issue in &chain.issues {
|
||||
let ref_icon = match issue.reference_type.as_str() {
|
||||
"closes" => Icons::issue_closed(),
|
||||
_ => Icons::issue_opened(),
|
||||
};
|
||||
|
||||
println!(
|
||||
" {} #{} {} {} [{}]",
|
||||
ref_icon,
|
||||
issue.iid,
|
||||
issue.title,
|
||||
Theme::dim().render(&issue.state),
|
||||
Theme::dim().render(&issue.reference_type),
|
||||
);
|
||||
}
|
||||
|
||||
// Discussions
|
||||
for disc in &chain.discussions {
|
||||
let date = disc.created_at_iso.split('T').next().unwrap_or("");
|
||||
println!(
|
||||
" {} @{} ({}) [{}]: {}",
|
||||
Icons::note(),
|
||||
disc.author_username,
|
||||
date,
|
||||
Theme::dim().render(&disc.path),
|
||||
disc.body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Robot (JSON) output ─────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum body length in robot JSON output (token efficiency).
|
||||
const ROBOT_BODY_SNIPPET_LEN: usize = 500;
|
||||
|
||||
fn truncate_body(body: &str, max: usize) -> String {
|
||||
if body.len() <= max {
|
||||
return body.to_string();
|
||||
}
|
||||
let boundary = body.floor_char_boundary(max);
|
||||
format!("{}...", &body[..boundary])
|
||||
}
|
||||
|
||||
pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: Option<u32>) {
|
||||
// Truncate discussion bodies for token efficiency in robot mode
|
||||
let chains: Vec<serde_json::Value> = result
|
||||
.trace_chains
|
||||
.iter()
|
||||
.map(|chain| {
|
||||
let discussions: Vec<serde_json::Value> = chain
|
||||
.discussions
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"discussion_id": d.discussion_id,
|
||||
"mr_iid": d.mr_iid,
|
||||
"author_username": d.author_username,
|
||||
"body_snippet": truncate_body(&d.body, ROBOT_BODY_SNIPPET_LEN),
|
||||
"path": d.path,
|
||||
"created_at_iso": d.created_at_iso,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"mr_iid": chain.mr_iid,
|
||||
"mr_title": chain.mr_title,
|
||||
"mr_state": chain.mr_state,
|
||||
"mr_author": chain.mr_author,
|
||||
"change_type": chain.change_type,
|
||||
"merged_at_iso": chain.merged_at_iso,
|
||||
"updated_at_iso": chain.updated_at_iso,
|
||||
"web_url": chain.web_url,
|
||||
"issues": chain.issues,
|
||||
"discussions": discussions,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"path": result.path,
|
||||
"resolved_paths": result.resolved_paths,
|
||||
"trace_chains": chains,
|
||||
},
|
||||
"meta": {
|
||||
"tier": "api_only",
|
||||
"line_requested": line_requested,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"total_chains": result.total_chains,
|
||||
"renames_followed": result.renames_followed,
|
||||
"hints": if result.hints.is_empty() { None } else { Some(&result.hints) },
|
||||
}
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_trace_path_simple() {
|
||||
let (path, line) = parse_trace_path("src/foo.rs");
|
||||
assert_eq!(path, "src/foo.rs");
|
||||
assert_eq!(line, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trace_path_with_line() {
|
||||
let (path, line) = parse_trace_path("src/foo.rs:42");
|
||||
assert_eq!(path, "src/foo.rs");
|
||||
assert_eq!(line, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trace_path_windows() {
|
||||
let (path, line) = parse_trace_path("C:/foo.rs");
|
||||
assert_eq!(path, "C:/foo.rs");
|
||||
assert_eq!(line, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trace_path_directory() {
|
||||
let (path, line) = parse_trace_path("src/auth/");
|
||||
assert_eq!(path, "src/auth/");
|
||||
assert_eq!(line, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trace_path_with_line_zero() {
|
||||
let (path, line) = parse_trace_path("file.rs:0");
|
||||
assert_eq!(path, "file.rs");
|
||||
assert_eq!(line, Some(0));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
301
src/cli/commands/who/active.rs
Normal file
301
src/cli/commands/who/active.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{self, Theme};
|
||||
use crate::core::error::Result;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
pub(super) fn query_active(
|
||||
conn: &Connection,
|
||||
project_id: Option<i64>,
|
||||
since_ms: i64,
|
||||
limit: usize,
|
||||
include_closed: bool,
|
||||
) -> Result<ActiveResult> {
|
||||
// Prevent overflow: saturating_add caps at usize::MAX instead of wrapping to 0.
|
||||
// The .min() ensures the value fits in i64 for SQLite's LIMIT clause.
|
||||
let limit_plus_one = limit.saturating_add(1).min(i64::MAX as usize) as i64;
|
||||
|
||||
// State filter for open-entities-only (default behavior)
|
||||
let state_joins = if include_closed {
|
||||
""
|
||||
} else {
|
||||
" LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id"
|
||||
};
|
||||
let state_filter = if include_closed {
|
||||
""
|
||||
} else {
|
||||
" AND (i.id IS NULL OR i.state = 'opened')
|
||||
AND (m.id IS NULL OR m.state = 'opened')"
|
||||
};
|
||||
|
||||
// Total unresolved count -- conditionally built
|
||||
let total_sql_global = format!(
|
||||
"SELECT COUNT(*) FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
{state_filter}"
|
||||
);
|
||||
let total_sql_scoped = format!(
|
||||
"SELECT COUNT(*) FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
AND d.project_id = ?2
|
||||
{state_filter}"
|
||||
);
|
||||
|
||||
let total_unresolved_in_window: u32 = match project_id {
|
||||
None => conn.query_row(&total_sql_global, rusqlite::params![since_ms], |row| {
|
||||
row.get(0)
|
||||
})?,
|
||||
Some(pid) => {
|
||||
conn.query_row(&total_sql_scoped, rusqlite::params![since_ms, pid], |row| {
|
||||
row.get(0)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
// Active discussions with context -- conditionally built SQL
|
||||
let sql_global = format!(
|
||||
"
|
||||
WITH picked AS (
|
||||
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
|
||||
d.project_id, d.last_note_at
|
||||
FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
{state_filter}
|
||||
ORDER BY d.last_note_at DESC
|
||||
LIMIT ?2
|
||||
),
|
||||
note_counts AS (
|
||||
SELECT
|
||||
n.discussion_id,
|
||||
COUNT(*) AS note_count
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0
|
||||
GROUP BY n.discussion_id
|
||||
),
|
||||
participants AS (
|
||||
SELECT
|
||||
x.discussion_id,
|
||||
GROUP_CONCAT(x.author_username, X'1F') AS participants
|
||||
FROM (
|
||||
SELECT DISTINCT n.discussion_id, n.author_username
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0 AND n.author_username IS NOT NULL
|
||||
) x
|
||||
GROUP BY x.discussion_id
|
||||
)
|
||||
SELECT
|
||||
p.id AS discussion_id,
|
||||
p.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS entity_iid,
|
||||
COALESCE(i.title, m.title) AS entity_title,
|
||||
proj.path_with_namespace,
|
||||
p.last_note_at,
|
||||
COALESCE(nc.note_count, 0) AS note_count,
|
||||
COALESCE(pa.participants, '') AS participants
|
||||
FROM picked p
|
||||
JOIN projects proj ON p.project_id = proj.id
|
||||
LEFT JOIN issues i ON p.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON p.merge_request_id = m.id
|
||||
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
|
||||
LEFT JOIN participants pa ON pa.discussion_id = p.id
|
||||
ORDER BY p.last_note_at DESC
|
||||
"
|
||||
);
|
||||
|
||||
let sql_scoped = format!(
|
||||
"
|
||||
WITH picked AS (
|
||||
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
|
||||
d.project_id, d.last_note_at
|
||||
FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
AND d.project_id = ?2
|
||||
{state_filter}
|
||||
ORDER BY d.last_note_at DESC
|
||||
LIMIT ?3
|
||||
),
|
||||
note_counts AS (
|
||||
SELECT
|
||||
n.discussion_id,
|
||||
COUNT(*) AS note_count
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0
|
||||
GROUP BY n.discussion_id
|
||||
),
|
||||
participants AS (
|
||||
SELECT
|
||||
x.discussion_id,
|
||||
GROUP_CONCAT(x.author_username, X'1F') AS participants
|
||||
FROM (
|
||||
SELECT DISTINCT n.discussion_id, n.author_username
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0 AND n.author_username IS NOT NULL
|
||||
) x
|
||||
GROUP BY x.discussion_id
|
||||
)
|
||||
SELECT
|
||||
p.id AS discussion_id,
|
||||
p.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS entity_iid,
|
||||
COALESCE(i.title, m.title) AS entity_title,
|
||||
proj.path_with_namespace,
|
||||
p.last_note_at,
|
||||
COALESCE(nc.note_count, 0) AS note_count,
|
||||
COALESCE(pa.participants, '') AS participants
|
||||
FROM picked p
|
||||
JOIN projects proj ON p.project_id = proj.id
|
||||
LEFT JOIN issues i ON p.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON p.merge_request_id = m.id
|
||||
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
|
||||
LEFT JOIN participants pa ON pa.discussion_id = p.id
|
||||
ORDER BY p.last_note_at DESC
|
||||
"
|
||||
);
|
||||
|
||||
// Row-mapping closure shared between both variants
|
||||
let map_row = |row: &rusqlite::Row| -> rusqlite::Result<ActiveDiscussion> {
|
||||
let noteable_type: String = row.get(1)?;
|
||||
let entity_type = if noteable_type == "MergeRequest" {
|
||||
"MR"
|
||||
} else {
|
||||
"Issue"
|
||||
};
|
||||
let participants_csv: Option<String> = row.get(7)?;
|
||||
// Sort participants for deterministic output -- GROUP_CONCAT order is undefined
|
||||
let mut participants: Vec<String> = participants_csv
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|csv| csv.split('\x1F').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
participants.sort();
|
||||
|
||||
const MAX_PARTICIPANTS: usize = 50;
|
||||
let participants_total = participants.len() as u32;
|
||||
let participants_truncated = participants.len() > MAX_PARTICIPANTS;
|
||||
if participants_truncated {
|
||||
participants.truncate(MAX_PARTICIPANTS);
|
||||
}
|
||||
|
||||
Ok(ActiveDiscussion {
|
||||
discussion_id: row.get(0)?,
|
||||
entity_type: entity_type.to_string(),
|
||||
entity_iid: row.get(2)?,
|
||||
entity_title: row.get(3)?,
|
||||
project_path: row.get(4)?,
|
||||
last_note_at: row.get(5)?,
|
||||
note_count: row.get(6)?,
|
||||
participants,
|
||||
participants_total,
|
||||
participants_truncated,
|
||||
})
|
||||
};
|
||||
|
||||
// Select variant first, then prepare exactly one statement
|
||||
let discussions: Vec<ActiveDiscussion> = match project_id {
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(&sql_global)?;
|
||||
stmt.query_map(rusqlite::params![since_ms, limit_plus_one], &map_row)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
Some(pid) => {
|
||||
let mut stmt = conn.prepare_cached(&sql_scoped)?;
|
||||
stmt.query_map(rusqlite::params![since_ms, pid, limit_plus_one], &map_row)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
};
|
||||
|
||||
let truncated = discussions.len() > limit;
|
||||
let discussions: Vec<ActiveDiscussion> = discussions.into_iter().take(limit).collect();
|
||||
|
||||
Ok(ActiveResult {
|
||||
discussions,
|
||||
total_unresolved_in_window,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!(
|
||||
"Active Discussions ({} unresolved in window)",
|
||||
r.total_unresolved_in_window
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
super::print_scope_hint(project_path);
|
||||
println!();
|
||||
|
||||
if r.discussions.is_empty() {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No active unresolved discussions in this time window.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
for disc in &r.discussions {
|
||||
let prefix = if disc.entity_type == "MR" { "!" } else { "#" };
|
||||
let participants_str = disc
|
||||
.participants
|
||||
.iter()
|
||||
.map(|p| format!("@{p}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
println!(
|
||||
" {} {} {} {} notes {}",
|
||||
Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
|
||||
render::truncate(&disc.entity_title, 40),
|
||||
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
|
||||
disc.note_count,
|
||||
Theme::dim().render(&disc.project_path),
|
||||
);
|
||||
if !participants_str.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&participants_str));
|
||||
}
|
||||
}
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub(super) fn active_to_json(r: &ActiveResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"total_unresolved_in_window": r.total_unresolved_in_window,
|
||||
"truncated": r.truncated,
|
||||
"discussions": r.discussions.iter().map(|d| serde_json::json!({
|
||||
"discussion_id": d.discussion_id,
|
||||
"entity_type": d.entity_type,
|
||||
"entity_iid": d.entity_iid,
|
||||
"entity_title": d.entity_title,
|
||||
"project_path": d.project_path,
|
||||
"last_note_at": ms_to_iso(d.last_note_at),
|
||||
"note_count": d.note_count,
|
||||
"participants": d.participants,
|
||||
"participants_total": d.participants_total,
|
||||
"participants_truncated": d.participants_truncated,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
839
src/cli/commands/who/expert.rs
Normal file
839
src/cli/commands/who/expert.rs
Normal file
@@ -0,0 +1,839 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use crate::core::config::ScoringConfig;
|
||||
use crate::core::error::Result;
|
||||
use crate::core::path_resolver::{PathQuery, build_path_query};
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
pub(super) fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
|
||||
let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
|
||||
let hl = f64::from(half_life_days);
|
||||
if hl <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
2.0_f64.powf(-days / hl)
|
||||
}
|
||||
|
||||
// ─── Query: Expert Mode ─────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn query_expert(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
project_id: Option<i64>,
|
||||
since_ms: i64,
|
||||
as_of_ms: i64,
|
||||
limit: usize,
|
||||
scoring: &ScoringConfig,
|
||||
detail: bool,
|
||||
explain_score: bool,
|
||||
include_bots: bool,
|
||||
) -> Result<ExpertResult> {
|
||||
let pq = build_path_query(conn, path, project_id)?;
|
||||
|
||||
let sql = build_expert_sql_v2(pq.is_prefix);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
|
||||
// Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms,
|
||||
// ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars
|
||||
let rows = stmt.query_map(
|
||||
rusqlite::params![
|
||||
pq.value,
|
||||
since_ms,
|
||||
project_id,
|
||||
as_of_ms,
|
||||
scoring.closed_mr_multiplier,
|
||||
scoring.reviewer_min_note_chars,
|
||||
],
|
||||
|row| {
|
||||
Ok(SignalRow {
|
||||
username: row.get(0)?,
|
||||
signal: row.get(1)?,
|
||||
mr_id: row.get(2)?,
|
||||
qty: row.get(3)?,
|
||||
ts: row.get(4)?,
|
||||
state_mult: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// Per-user accumulator keyed by username.
|
||||
let mut accum: HashMap<String, UserAccum> = HashMap::new();
|
||||
|
||||
for row_result in rows {
|
||||
let r = row_result?;
|
||||
let entry = accum
|
||||
.entry(r.username.clone())
|
||||
.or_insert_with(|| UserAccum {
|
||||
contributions: Vec::new(),
|
||||
last_seen_ms: 0,
|
||||
mr_ids_author: HashSet::new(),
|
||||
mr_ids_reviewer: HashSet::new(),
|
||||
note_count: 0,
|
||||
});
|
||||
|
||||
if r.ts > entry.last_seen_ms {
|
||||
entry.last_seen_ms = r.ts;
|
||||
}
|
||||
|
||||
match r.signal.as_str() {
|
||||
"diffnote_author" | "file_author" => {
|
||||
entry.mr_ids_author.insert(r.mr_id);
|
||||
}
|
||||
"file_reviewer_participated" | "file_reviewer_assigned" => {
|
||||
entry.mr_ids_reviewer.insert(r.mr_id);
|
||||
}
|
||||
"note_group" => {
|
||||
entry.note_count += r.qty as u32;
|
||||
// DiffNote reviewers are also reviewer activity.
|
||||
entry.mr_ids_reviewer.insert(r.mr_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
entry.contributions.push(Contribution {
|
||||
signal: r.signal,
|
||||
mr_id: r.mr_id,
|
||||
qty: r.qty,
|
||||
ts: r.ts,
|
||||
state_mult: r.state_mult,
|
||||
});
|
||||
}
|
||||
|
||||
// Bot filtering: exclude configured bot usernames (case-insensitive).
|
||||
if !include_bots && !scoring.excluded_usernames.is_empty() {
|
||||
let excluded: HashSet<String> = scoring
|
||||
.excluded_usernames
|
||||
.iter()
|
||||
.map(|u| u.to_lowercase())
|
||||
.collect();
|
||||
accum.retain(|username, _| !excluded.contains(&username.to_lowercase()));
|
||||
}
|
||||
|
||||
// Compute decayed scores with deterministic ordering.
|
||||
let mut scored: Vec<ScoredUser> = accum
|
||||
.into_iter()
|
||||
.map(|(username, mut ua)| {
|
||||
// Sort contributions by mr_id ASC for deterministic f64 summation.
|
||||
ua.contributions.sort_by_key(|c| c.mr_id);
|
||||
|
||||
let mut comp_author = 0.0_f64;
|
||||
let mut comp_reviewer_participated = 0.0_f64;
|
||||
let mut comp_reviewer_assigned = 0.0_f64;
|
||||
let mut comp_notes = 0.0_f64;
|
||||
|
||||
for c in &ua.contributions {
|
||||
let elapsed = as_of_ms - c.ts;
|
||||
match c.signal.as_str() {
|
||||
"diffnote_author" | "file_author" => {
|
||||
let decay = half_life_decay(elapsed, scoring.author_half_life_days);
|
||||
comp_author += scoring.author_weight as f64 * decay * c.state_mult;
|
||||
}
|
||||
"file_reviewer_participated" => {
|
||||
let decay = half_life_decay(elapsed, scoring.reviewer_half_life_days);
|
||||
comp_reviewer_participated +=
|
||||
scoring.reviewer_weight as f64 * decay * c.state_mult;
|
||||
}
|
||||
"file_reviewer_assigned" => {
|
||||
let decay =
|
||||
half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days);
|
||||
comp_reviewer_assigned +=
|
||||
scoring.reviewer_assignment_weight as f64 * decay * c.state_mult;
|
||||
}
|
||||
"note_group" => {
|
||||
let decay = half_life_decay(elapsed, scoring.note_half_life_days);
|
||||
// Diminishing returns: log2(1 + count) per MR.
|
||||
let note_value = (1.0 + c.qty as f64).log2();
|
||||
comp_notes += scoring.note_bonus as f64 * note_value * decay * c.state_mult;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let raw_score =
|
||||
comp_author + comp_reviewer_participated + comp_reviewer_assigned + comp_notes;
|
||||
ScoredUser {
|
||||
username,
|
||||
raw_score,
|
||||
components: ScoreComponents {
|
||||
author: comp_author,
|
||||
reviewer_participated: comp_reviewer_participated,
|
||||
reviewer_assigned: comp_reviewer_assigned,
|
||||
notes: comp_notes,
|
||||
},
|
||||
accum: ua,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: raw_score DESC, last_seen DESC, username ASC (deterministic tiebreaker).
|
||||
scored.sort_by(|a, b| {
|
||||
b.raw_score
|
||||
.partial_cmp(&a.raw_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| b.accum.last_seen_ms.cmp(&a.accum.last_seen_ms))
|
||||
.then_with(|| a.username.cmp(&b.username))
|
||||
});
|
||||
|
||||
let truncated = scored.len() > limit;
|
||||
scored.truncate(limit);
|
||||
|
||||
// Build Expert structs with MR refs.
|
||||
let mut experts: Vec<Expert> = scored
|
||||
.into_iter()
|
||||
.map(|su| {
|
||||
let mut mr_refs = build_mr_refs_for_user(conn, &su.accum);
|
||||
mr_refs.sort();
|
||||
let mr_refs_total = mr_refs.len() as u32;
|
||||
let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
|
||||
if mr_refs_truncated {
|
||||
mr_refs.truncate(MAX_MR_REFS_PER_USER);
|
||||
}
|
||||
Expert {
|
||||
username: su.username,
|
||||
score: su.raw_score.round() as i64,
|
||||
score_raw: if explain_score {
|
||||
Some(su.raw_score)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
components: if explain_score {
|
||||
Some(su.components)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
review_mr_count: su.accum.mr_ids_reviewer.len() as u32,
|
||||
review_note_count: su.accum.note_count,
|
||||
author_mr_count: su.accum.mr_ids_author.len() as u32,
|
||||
last_seen_ms: su.accum.last_seen_ms,
|
||||
mr_refs,
|
||||
mr_refs_total,
|
||||
mr_refs_truncated,
|
||||
details: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Populate per-MR detail when --detail is requested
|
||||
if detail && !experts.is_empty() {
|
||||
let details_map = query_expert_details(conn, &pq, &experts, since_ms, project_id)?;
|
||||
for expert in &mut experts {
|
||||
expert.details = details_map.get(&expert.username).cloned();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExpertResult {
|
||||
path_query: if pq.is_prefix {
|
||||
// Use raw input (unescaped) for display — pq.value has LIKE escaping.
|
||||
path.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
// For exact matches (including suffix-resolved), show the resolved path.
|
||||
pq.value.clone()
|
||||
},
|
||||
path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(),
|
||||
experts,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
struct SignalRow {
|
||||
username: String,
|
||||
signal: String,
|
||||
mr_id: i64,
|
||||
qty: i64,
|
||||
ts: i64,
|
||||
state_mult: f64,
|
||||
}
|
||||
|
||||
/// Per-user signal accumulator used during Rust-side scoring.
|
||||
struct UserAccum {
|
||||
contributions: Vec<Contribution>,
|
||||
last_seen_ms: i64,
|
||||
mr_ids_author: HashSet<i64>,
|
||||
mr_ids_reviewer: HashSet<i64>,
|
||||
note_count: u32,
|
||||
}
|
||||
|
||||
/// A single contribution to a user's score (one signal row).
|
||||
struct Contribution {
|
||||
signal: String,
|
||||
mr_id: i64,
|
||||
qty: i64,
|
||||
ts: i64,
|
||||
state_mult: f64,
|
||||
}
|
||||
|
||||
/// Intermediate scored user before building Expert structs.
|
||||
struct ScoredUser {
|
||||
username: String,
|
||||
raw_score: f64,
|
||||
components: ScoreComponents,
|
||||
accum: UserAccum,
|
||||
}
|
||||
|
||||
/// Build MR refs (e.g. "group/project!123") for a user from their accumulated MR IDs.
|
||||
fn build_mr_refs_for_user(conn: &Connection, ua: &UserAccum) -> Vec<String> {
|
||||
let all_mr_ids: HashSet<i64> = ua
|
||||
.mr_ids_author
|
||||
.iter()
|
||||
.chain(ua.mr_ids_reviewer.iter())
|
||||
.copied()
|
||||
.chain(ua.contributions.iter().map(|c| c.mr_id))
|
||||
.collect();
|
||||
|
||||
if all_mr_ids.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let placeholders: Vec<String> = (1..=all_mr_ids.len()).map(|i| format!("?{i}")).collect();
|
||||
let sql = format!(
|
||||
"SELECT p.path_with_namespace || '!' || CAST(m.iid AS TEXT)
|
||||
FROM merge_requests m
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.id IN ({})",
|
||||
placeholders.join(",")
|
||||
);
|
||||
|
||||
let mut stmt = match conn.prepare(&sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut mr_ids_vec: Vec<i64> = all_mr_ids.into_iter().collect();
|
||||
mr_ids_vec.sort_unstable();
|
||||
let params: Vec<&dyn rusqlite::types::ToSql> = mr_ids_vec
|
||||
.iter()
|
||||
.map(|id| id as &dyn rusqlite::types::ToSql)
|
||||
.collect();
|
||||
|
||||
stmt.query_map(&*params, |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build the CTE-based expert SQL for time-decay scoring (v2).
|
||||
///
|
||||
/// Returns raw signal rows `(username, signal, mr_id, qty, ts, state_mult)` that
|
||||
/// Rust aggregates with per-signal decay and `log2(1+count)` for note groups.
|
||||
///
|
||||
/// Parameters: `?1` = path, `?2` = since_ms, `?3` = project_id (nullable),
|
||||
/// `?4` = as_of_ms, `?5` = closed_mr_multiplier, `?6` = reviewer_min_note_chars
|
||||
pub(super) fn build_expert_sql_v2(is_prefix: bool) -> String {
|
||||
let path_op = if is_prefix {
|
||||
"LIKE ?1 ESCAPE '\\'"
|
||||
} else {
|
||||
"= ?1"
|
||||
};
|
||||
// INDEXED BY hints for each branch:
|
||||
// - new_path branch: idx_notes_diffnote_path_created (existing)
|
||||
// - old_path branch: idx_notes_old_path_author (migration 026)
|
||||
format!(
|
||||
"
|
||||
WITH matched_notes_raw AS (
|
||||
-- Branch 1: match on position_new_path
|
||||
SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id
|
||||
FROM notes n INDEXED BY idx_notes_diffnote_path_created
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND n.created_at < ?4
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
AND n.position_new_path {path_op}
|
||||
UNION ALL
|
||||
-- Branch 2: match on position_old_path
|
||||
SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id
|
||||
FROM notes n INDEXED BY idx_notes_old_path_author
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND n.created_at < ?4
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
AND n.position_old_path IS NOT NULL
|
||||
AND n.position_old_path {path_op}
|
||||
),
|
||||
matched_notes AS (
|
||||
-- Dedup: prevent double-counting when old_path = new_path (no rename)
|
||||
SELECT DISTINCT id, discussion_id, author_username, created_at, project_id
|
||||
FROM matched_notes_raw
|
||||
),
|
||||
matched_file_changes_raw AS (
|
||||
-- Branch 1: match on new_path
|
||||
SELECT fc.merge_request_id, fc.project_id
|
||||
FROM mr_file_changes fc INDEXED BY idx_mfc_new_path_project_mr
|
||||
WHERE (?3 IS NULL OR fc.project_id = ?3)
|
||||
AND fc.new_path {path_op}
|
||||
UNION ALL
|
||||
-- Branch 2: match on old_path
|
||||
SELECT fc.merge_request_id, fc.project_id
|
||||
FROM mr_file_changes fc INDEXED BY idx_mfc_old_path_project_mr
|
||||
WHERE (?3 IS NULL OR fc.project_id = ?3)
|
||||
AND fc.old_path IS NOT NULL
|
||||
AND fc.old_path {path_op}
|
||||
),
|
||||
matched_file_changes AS (
|
||||
-- Dedup: prevent double-counting when old_path = new_path (no rename)
|
||||
SELECT DISTINCT merge_request_id, project_id
|
||||
FROM matched_file_changes_raw
|
||||
),
|
||||
mr_activity AS (
|
||||
-- Centralized state-aware timestamps and state multiplier.
|
||||
-- Scoped to MRs matched by file changes to avoid materializing the full MR table.
|
||||
SELECT DISTINCT
|
||||
m.id AS mr_id,
|
||||
m.author_username,
|
||||
m.state,
|
||||
CASE
|
||||
WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
||||
WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
||||
ELSE COALESCE(m.updated_at, m.created_at)
|
||||
END AS activity_ts,
|
||||
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||
FROM merge_requests m
|
||||
JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id
|
||||
WHERE m.state IN ('opened','merged','closed')
|
||||
),
|
||||
reviewer_participation AS (
|
||||
-- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
|
||||
SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username
|
||||
FROM matched_notes mn
|
||||
JOIN discussions d ON mn.discussion_id = d.id
|
||||
JOIN notes n_body ON mn.id = n_body.id
|
||||
WHERE d.merge_request_id IS NOT NULL
|
||||
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
|
||||
),
|
||||
raw AS (
|
||||
-- Signal 1: DiffNote reviewer (individual notes for note_cnt)
|
||||
SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal,
|
||||
m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at,
|
||||
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||
FROM matched_notes mn
|
||||
JOIN discussions d ON mn.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE (m.author_username IS NULL OR mn.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 2: DiffNote MR author
|
||||
SELECT m.author_username AS username, 'diffnote_author' AS signal,
|
||||
m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at,
|
||||
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN matched_notes mn ON mn.discussion_id = d.id
|
||||
WHERE m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
GROUP BY m.author_username, m.id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 3: MR author via file changes (uses mr_activity CTE)
|
||||
SELECT a.author_username AS username, 'file_author' AS signal,
|
||||
a.mr_id, NULL AS note_id,
|
||||
a.activity_ts AS seen_at, a.state_mult
|
||||
FROM mr_activity a
|
||||
WHERE a.author_username IS NOT NULL
|
||||
AND a.activity_ts >= ?2
|
||||
AND a.activity_ts < ?4
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
|
||||
SELECT r.username AS username, 'file_reviewer_participated' AS signal,
|
||||
a.mr_id, NULL AS note_id,
|
||||
a.activity_ts AS seen_at, a.state_mult
|
||||
FROM mr_activity a
|
||||
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||
JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||
WHERE r.username IS NOT NULL
|
||||
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||
AND a.activity_ts >= ?2
|
||||
AND a.activity_ts < ?4
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
|
||||
SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
|
||||
a.mr_id, NULL AS note_id,
|
||||
a.activity_ts AS seen_at, a.state_mult
|
||||
FROM mr_activity a
|
||||
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||
LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||
WHERE rp.username IS NULL
|
||||
AND r.username IS NOT NULL
|
||||
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||
AND a.activity_ts >= ?2
|
||||
AND a.activity_ts < ?4
|
||||
),
|
||||
aggregated AS (
|
||||
-- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts)
|
||||
SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult
|
||||
FROM raw WHERE signal != 'diffnote_reviewer'
|
||||
GROUP BY username, signal, mr_id
|
||||
UNION ALL
|
||||
-- Note signals: 1 row per (username, mr_id) with note_count and max_ts
|
||||
SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts,
|
||||
MAX(state_mult) AS state_mult
|
||||
FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
|
||||
GROUP BY username, mr_id
|
||||
)
|
||||
SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL
|
||||
"
|
||||
)
|
||||
}
|
||||
|
||||
/// Query per-MR detail for a set of experts. Returns a map of username -> Vec<ExpertMrDetail>.
|
||||
pub(super) fn query_expert_details(
|
||||
conn: &Connection,
|
||||
pq: &PathQuery,
|
||||
experts: &[Expert],
|
||||
since_ms: i64,
|
||||
project_id: Option<i64>,
|
||||
) -> Result<HashMap<String, Vec<ExpertMrDetail>>> {
|
||||
let path_op = if pq.is_prefix {
|
||||
"LIKE ?1 ESCAPE '\\'"
|
||||
} else {
|
||||
"= ?1"
|
||||
};
|
||||
|
||||
// Build IN clause for usernames
|
||||
let placeholders: Vec<String> = experts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("?{}", i + 4))
|
||||
.collect();
|
||||
let in_clause = placeholders.join(",");
|
||||
|
||||
let sql = format!(
|
||||
"
|
||||
WITH signals AS (
|
||||
-- 1. DiffNote reviewer (matches both new_path and old_path for renamed files)
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
m.id AS mr_id,
|
||||
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
|
||||
m.title AS title,
|
||||
COUNT(*) AS note_count,
|
||||
MAX(n.created_at) AS last_activity
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND (n.position_new_path {path_op}
|
||||
OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
AND n.author_username IN ({in_clause})
|
||||
GROUP BY n.author_username, m.id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 2. DiffNote MR author (matches both new_path and old_path for renamed files)
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
m.id AS mr_id,
|
||||
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
|
||||
m.title AS title,
|
||||
0 AS note_count,
|
||||
MAX(n.created_at) AS last_activity
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND (n.position_new_path {path_op}
|
||||
OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
AND m.author_username IN ({in_clause})
|
||||
GROUP BY m.author_username, m.id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 3. MR author via file changes (matches both new_path and old_path)
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
m.id AS mr_id,
|
||||
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
|
||||
m.title AS title,
|
||||
0 AS note_count,
|
||||
m.updated_at AS last_activity
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND (fc.new_path {path_op}
|
||||
OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
AND m.author_username IN ({in_clause})
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path)
|
||||
SELECT
|
||||
r.username AS username,
|
||||
'reviewer' AS role,
|
||||
m.id AS mr_id,
|
||||
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
|
||||
m.title AS title,
|
||||
0 AS note_count,
|
||||
m.updated_at AS last_activity
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
WHERE r.username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND (fc.new_path {path_op}
|
||||
OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
AND r.username IN ({in_clause})
|
||||
)
|
||||
SELECT
|
||||
username,
|
||||
mr_ref,
|
||||
title,
|
||||
GROUP_CONCAT(DISTINCT role) AS roles,
|
||||
SUM(note_count) AS total_notes,
|
||||
MAX(last_activity) AS last_activity
|
||||
FROM signals
|
||||
GROUP BY username, mr_ref
|
||||
ORDER BY username ASC, last_activity DESC
|
||||
"
|
||||
);
|
||||
|
||||
// prepare() not prepare_cached(): the IN clause varies by expert count,
|
||||
// so the SQL shape changes per invocation and caching wastes memory.
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
// Build params: ?1=path, ?2=since_ms, ?3=project_id, ?4..=usernames
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
params.push(Box::new(pq.value.clone()));
|
||||
params.push(Box::new(since_ms));
|
||||
params.push(Box::new(project_id));
|
||||
for expert in experts {
|
||||
params.push(Box::new(expert.username.clone()));
|
||||
}
|
||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows: Vec<(String, String, String, String, u32, i64)> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut map: HashMap<String, Vec<ExpertMrDetail>> = HashMap::new();
|
||||
for (username, mr_ref, title, roles_csv, note_count, last_activity) in rows {
|
||||
let has_author = roles_csv.contains("author");
|
||||
let has_reviewer = roles_csv.contains("reviewer");
|
||||
let role = match (has_author, has_reviewer) {
|
||||
(true, true) => "A+R",
|
||||
(true, false) => "A",
|
||||
(false, true) => "R",
|
||||
_ => "?",
|
||||
}
|
||||
.to_string();
|
||||
map.entry(username).or_default().push(ExpertMrDetail {
|
||||
mr_ref,
|
||||
title,
|
||||
role,
|
||||
note_count,
|
||||
last_activity_ms: last_activity,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(super) fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("Experts for {}", r.path_query))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!(
|
||||
"(matching {} {})",
|
||||
r.path_match,
|
||||
if r.path_match == "exact" {
|
||||
"file"
|
||||
} else {
|
||||
"directory prefix"
|
||||
}
|
||||
))
|
||||
);
|
||||
super::print_scope_hint(project_path);
|
||||
println!();
|
||||
|
||||
if r.experts.is_empty() {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No experts found for this path.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
" {:<16} {:>6} {:>12} {:>6} {:>12} {} {}",
|
||||
Theme::bold().render("Username"),
|
||||
Theme::bold().render("Score"),
|
||||
Theme::bold().render("Reviewed(MRs)"),
|
||||
Theme::bold().render("Notes"),
|
||||
Theme::bold().render("Authored(MRs)"),
|
||||
Theme::bold().render("Last Seen"),
|
||||
Theme::bold().render("MR Refs"),
|
||||
);
|
||||
|
||||
for expert in &r.experts {
|
||||
let reviews = if expert.review_mr_count > 0 {
|
||||
expert.review_mr_count.to_string()
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let notes = if expert.review_note_count > 0 {
|
||||
expert.review_note_count.to_string()
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let authored = if expert.author_mr_count > 0 {
|
||||
expert.author_mr_count.to_string()
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let mr_str = expert
|
||||
.mr_refs
|
||||
.iter()
|
||||
.take(5)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let overflow = if expert.mr_refs_total > 5 {
|
||||
format!(" +{}", expert.mr_refs_total - 5)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(
|
||||
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
|
||||
Theme::info().render(&format!("{} {}", Icons::user(), expert.username)),
|
||||
expert.score,
|
||||
reviews,
|
||||
notes,
|
||||
authored,
|
||||
render::format_relative_time(expert.last_seen_ms),
|
||||
if mr_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {mr_str}")
|
||||
},
|
||||
overflow,
|
||||
);
|
||||
|
||||
// Print detail sub-rows when populated
|
||||
if let Some(details) = &expert.details {
|
||||
const MAX_DETAIL_DISPLAY: usize = 10;
|
||||
for d in details.iter().take(MAX_DETAIL_DISPLAY) {
|
||||
let notes_str = if d.note_count > 0 {
|
||||
format!("{} notes", d.note_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(
|
||||
" {:<3} {:<30} {:>30} {:>10} {}",
|
||||
Theme::dim().render(&d.role),
|
||||
d.mr_ref,
|
||||
render::truncate(&format!("\"{}\"", d.title), 30),
|
||||
notes_str,
|
||||
Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
|
||||
);
|
||||
}
|
||||
if details.len() > MAX_DETAIL_DISPLAY {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub(super) fn expert_to_json(r: &ExpertResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"path_query": r.path_query,
|
||||
"path_match": r.path_match,
|
||||
"scoring_model_version": 2,
|
||||
"truncated": r.truncated,
|
||||
"experts": r.experts.iter().map(|e| {
|
||||
let mut obj = serde_json::json!({
|
||||
"username": e.username,
|
||||
"score": e.score,
|
||||
"review_mr_count": e.review_mr_count,
|
||||
"review_note_count": e.review_note_count,
|
||||
"author_mr_count": e.author_mr_count,
|
||||
"last_seen_at": ms_to_iso(e.last_seen_ms),
|
||||
"mr_refs": e.mr_refs,
|
||||
"mr_refs_total": e.mr_refs_total,
|
||||
"mr_refs_truncated": e.mr_refs_truncated,
|
||||
});
|
||||
if let Some(raw) = e.score_raw {
|
||||
obj["score_raw"] = serde_json::json!(raw);
|
||||
}
|
||||
if let Some(comp) = &e.components {
|
||||
obj["components"] = serde_json::json!({
|
||||
"author": comp.author,
|
||||
"reviewer_participated": comp.reviewer_participated,
|
||||
"reviewer_assigned": comp.reviewer_assigned,
|
||||
"notes": comp.notes,
|
||||
});
|
||||
}
|
||||
if let Some(details) = &e.details {
|
||||
obj["details"] = serde_json::json!(details.iter().map(|d| serde_json::json!({
|
||||
"mr_ref": d.mr_ref,
|
||||
"title": d.title,
|
||||
"role": d.role,
|
||||
"note_count": d.note_count,
|
||||
"last_activity_at": ms_to_iso(d.last_activity_ms),
|
||||
})).collect::<Vec<_>>());
|
||||
}
|
||||
obj
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
429
src/cli/commands/who/mod.rs
Normal file
429
src/cli/commands/who/mod.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
mod active;
|
||||
mod expert;
|
||||
mod overlap;
|
||||
mod reviews;
|
||||
pub mod types;
|
||||
mod workload;
|
||||
|
||||
pub use types::*;
|
||||
|
||||
// Re-export submodule functions for tests (tests use `use super::*`).
|
||||
#[cfg(test)]
|
||||
use active::query_active;
|
||||
#[cfg(test)]
|
||||
use expert::{build_expert_sql_v2, half_life_decay, query_expert};
|
||||
#[cfg(test)]
|
||||
use overlap::{format_overlap_role, query_overlap};
|
||||
#[cfg(test)]
|
||||
use reviews::{normalize_review_prefix, query_reviews};
|
||||
#[cfg(test)]
|
||||
use workload::query_workload;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::WhoArgs;
|
||||
use crate::cli::render::Theme;
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::path_resolver::normalize_repo_path;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::{ms_to_iso, now_ms, parse_since, parse_since_from};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::core::config::ScoringConfig;
|
||||
#[cfg(test)]
|
||||
use crate::core::path_resolver::{SuffixResult, build_path_query, escape_like, suffix_probe};
|
||||
|
||||
// ─── Mode Discrimination ────────────────────────────────────────────────────
|
||||
|
||||
/// Determines which query mode to run based on args.
|
||||
/// Path variants own their strings because path normalization produces new `String`s.
|
||||
/// Username variants borrow from args since no normalization is needed.
|
||||
enum WhoMode<'a> {
|
||||
/// lore who <file-path> OR lore who --path <path>
|
||||
Expert { path: String },
|
||||
/// lore who <username>
|
||||
Workload { username: &'a str },
|
||||
/// lore who <username> --reviews
|
||||
Reviews { username: &'a str },
|
||||
/// lore who --active
|
||||
Active,
|
||||
/// lore who --overlap <path>
|
||||
Overlap { path: String },
|
||||
}
|
||||
|
||||
fn resolve_mode<'a>(args: &'a WhoArgs) -> Result<WhoMode<'a>> {
|
||||
// Explicit --path flag always wins (handles root files like README.md,
|
||||
// LICENSE, Makefile -- anything without a / that can't be auto-detected)
|
||||
if let Some(p) = &args.path {
|
||||
return Ok(WhoMode::Expert {
|
||||
path: normalize_repo_path(p),
|
||||
});
|
||||
}
|
||||
if args.active {
|
||||
return Ok(WhoMode::Active);
|
||||
}
|
||||
if let Some(path) = &args.overlap {
|
||||
return Ok(WhoMode::Overlap {
|
||||
path: normalize_repo_path(path),
|
||||
});
|
||||
}
|
||||
if let Some(target) = &args.target {
|
||||
let clean = target.strip_prefix('@').unwrap_or(target);
|
||||
if args.reviews {
|
||||
return Ok(WhoMode::Reviews { username: clean });
|
||||
}
|
||||
// Disambiguation: if target contains '/', it's a file path.
|
||||
// GitLab usernames never contain '/'.
|
||||
// Root files (no '/') require --path.
|
||||
if clean.contains('/') {
|
||||
return Ok(WhoMode::Expert {
|
||||
path: normalize_repo_path(clean),
|
||||
});
|
||||
}
|
||||
return Ok(WhoMode::Workload { username: clean });
|
||||
}
|
||||
Err(LoreError::Other(
|
||||
"Provide a username, file path, --active, or --overlap <path>.\n\n\
|
||||
Examples:\n \
|
||||
lore who src/features/auth/\n \
|
||||
lore who @username\n \
|
||||
lore who --active\n \
|
||||
lore who --overlap src/features/\n \
|
||||
lore who --path README.md\n \
|
||||
lore who --path Makefile"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_mode_flags(mode: &WhoMode<'_>, args: &WhoArgs) -> Result<()> {
|
||||
if args.detail && !matches!(mode, WhoMode::Expert { .. }) {
|
||||
return Err(LoreError::Other(
|
||||
"--detail is only supported in expert mode (`lore who --path <path>` or `lore who <path/with/slash>`).".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Entry Point ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Main entry point. Resolves mode + resolved inputs once, then dispatches.
|
||||
pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let project_id = args
|
||||
.project
|
||||
.as_deref()
|
||||
.map(|p| resolve_project(&conn, p))
|
||||
.transpose()?;
|
||||
|
||||
let project_path = project_id
|
||||
.map(|id| lookup_project_path(&conn, id))
|
||||
.transpose()?;
|
||||
|
||||
let mode = resolve_mode(args)?;
|
||||
validate_mode_flags(&mode, args)?;
|
||||
|
||||
// since_mode semantics:
|
||||
// - expert/reviews/active/overlap: default window applies if args.since is None -> "default"
|
||||
// - workload: no default window; args.since None => "none"
|
||||
let since_mode_for_defaulted = if args.since.is_some() {
|
||||
"explicit"
|
||||
} else {
|
||||
"default"
|
||||
};
|
||||
let since_mode_for_workload = if args.since.is_some() {
|
||||
"explicit"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
|
||||
let limit = args.limit.map_or(usize::MAX, usize::from);
|
||||
|
||||
match mode {
|
||||
WhoMode::Expert { path } => {
|
||||
// Compute as_of first so --since durations are relative to it.
|
||||
let as_of_ms = match &args.as_of {
|
||||
Some(v) => parse_since(v).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --as-of value: '{v}'. Use a duration (30d, 6m) or date (2024-01-15)"
|
||||
))
|
||||
})?,
|
||||
None => now_ms(),
|
||||
};
|
||||
let since_ms = if args.all_history {
|
||||
0
|
||||
} else {
|
||||
resolve_since_from(args.since.as_deref(), "24m", as_of_ms)?
|
||||
};
|
||||
let result = expert::query_expert(
|
||||
&conn,
|
||||
&path,
|
||||
project_id,
|
||||
since_ms,
|
||||
as_of_ms,
|
||||
limit,
|
||||
&config.scoring,
|
||||
args.detail,
|
||||
args.explain_score,
|
||||
args.include_bots,
|
||||
)?;
|
||||
Ok(WhoRun {
|
||||
resolved_input: WhoResolvedInput {
|
||||
mode: "expert".to_string(),
|
||||
project_id,
|
||||
project_path,
|
||||
since_ms: Some(since_ms),
|
||||
since_iso: Some(ms_to_iso(since_ms)),
|
||||
since_mode: since_mode_for_defaulted.to_string(),
|
||||
limit: args.limit,
|
||||
},
|
||||
result: WhoResult::Expert(result),
|
||||
})
|
||||
}
|
||||
WhoMode::Workload { username } => {
|
||||
let since_ms = args
|
||||
.since
|
||||
.as_deref()
|
||||
.map(resolve_since_required)
|
||||
.transpose()?;
|
||||
|
||||
let result = workload::query_workload(
|
||||
&conn,
|
||||
username,
|
||||
project_id,
|
||||
since_ms,
|
||||
limit,
|
||||
args.include_closed,
|
||||
)?;
|
||||
Ok(WhoRun {
|
||||
resolved_input: WhoResolvedInput {
|
||||
mode: "workload".to_string(),
|
||||
project_id,
|
||||
project_path,
|
||||
since_ms,
|
||||
since_iso: since_ms.map(ms_to_iso),
|
||||
since_mode: since_mode_for_workload.to_string(),
|
||||
limit: args.limit,
|
||||
},
|
||||
result: WhoResult::Workload(result),
|
||||
})
|
||||
}
|
||||
WhoMode::Reviews { username } => {
|
||||
let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
||||
let result = reviews::query_reviews(&conn, username, project_id, since_ms)?;
|
||||
Ok(WhoRun {
|
||||
resolved_input: WhoResolvedInput {
|
||||
mode: "reviews".to_string(),
|
||||
project_id,
|
||||
project_path,
|
||||
since_ms: Some(since_ms),
|
||||
since_iso: Some(ms_to_iso(since_ms)),
|
||||
since_mode: since_mode_for_defaulted.to_string(),
|
||||
limit: args.limit,
|
||||
},
|
||||
result: WhoResult::Reviews(result),
|
||||
})
|
||||
}
|
||||
WhoMode::Active => {
|
||||
let since_ms = resolve_since(args.since.as_deref(), "7d")?;
|
||||
|
||||
let result =
|
||||
active::query_active(&conn, project_id, since_ms, limit, args.include_closed)?;
|
||||
Ok(WhoRun {
|
||||
resolved_input: WhoResolvedInput {
|
||||
mode: "active".to_string(),
|
||||
project_id,
|
||||
project_path,
|
||||
since_ms: Some(since_ms),
|
||||
since_iso: Some(ms_to_iso(since_ms)),
|
||||
since_mode: since_mode_for_defaulted.to_string(),
|
||||
limit: args.limit,
|
||||
},
|
||||
result: WhoResult::Active(result),
|
||||
})
|
||||
}
|
||||
WhoMode::Overlap { path } => {
|
||||
let since_ms = resolve_since(args.since.as_deref(), "30d")?;
|
||||
|
||||
let result = overlap::query_overlap(&conn, &path, project_id, since_ms, limit)?;
|
||||
Ok(WhoRun {
|
||||
resolved_input: WhoResolvedInput {
|
||||
mode: "overlap".to_string(),
|
||||
project_id,
|
||||
project_path,
|
||||
since_ms: Some(since_ms),
|
||||
since_iso: Some(ms_to_iso(since_ms)),
|
||||
since_mode: since_mode_for_defaulted.to_string(),
|
||||
limit: args.limit,
|
||||
},
|
||||
result: WhoResult::Overlap(result),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Look up the project path for a resolved project ID.
|
||||
fn lookup_project_path(conn: &Connection, project_id: i64) -> Result<String> {
|
||||
conn.query_row(
|
||||
"SELECT path_with_namespace FROM projects WHERE id = ?1",
|
||||
rusqlite::params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to look up project path: {e}")))
|
||||
}
|
||||
|
||||
/// Parse --since with a default fallback.
|
||||
fn resolve_since(input: Option<&str>, default: &str) -> Result<i64> {
|
||||
let s = input.unwrap_or(default);
|
||||
parse_since(s).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse --since with a default fallback, relative to a reference timestamp.
|
||||
/// Durations (7d, 2w, 6m) are computed from `reference_ms` instead of now.
|
||||
fn resolve_since_from(input: Option<&str>, default: &str, reference_ms: i64) -> Result<i64> {
|
||||
let s = input.unwrap_or(default);
|
||||
parse_since_from(s, reference_ms).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse --since without a default (returns error if invalid).
|
||||
fn resolve_since_required(input: &str) -> Result<i64> {
|
||||
parse_since(input).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --since value: '{input}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Human Output ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
|
||||
match result {
|
||||
WhoResult::Expert(r) => expert::print_expert_human(r, project_path),
|
||||
WhoResult::Workload(r) => workload::print_workload_human(r),
|
||||
WhoResult::Reviews(r) => reviews::print_reviews_human(r),
|
||||
WhoResult::Active(r) => active::print_active_human(r, project_path),
|
||||
WhoResult::Overlap(r) => overlap::print_overlap_human(r, project_path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a dim hint when results aggregate across all projects.
|
||||
pub(super) fn print_scope_hint(project_path: Option<&str>) {
|
||||
if project_path.is_none() {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(aggregated across all projects; use -p to scope)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Robot JSON Output ───────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
|
||||
let (mode, data) = match &run.result {
|
||||
WhoResult::Expert(r) => ("expert", expert::expert_to_json(r)),
|
||||
WhoResult::Workload(r) => ("workload", workload::workload_to_json(r)),
|
||||
WhoResult::Reviews(r) => ("reviews", reviews::reviews_to_json(r)),
|
||||
WhoResult::Active(r) => ("active", active::active_to_json(r)),
|
||||
WhoResult::Overlap(r) => ("overlap", overlap::overlap_to_json(r)),
|
||||
};
|
||||
|
||||
// Raw CLI args -- what the user typed
|
||||
let input = serde_json::json!({
|
||||
"target": args.target,
|
||||
"path": args.path,
|
||||
"project": args.project,
|
||||
"since": args.since,
|
||||
"limit": args.limit,
|
||||
"detail": args.detail,
|
||||
"as_of": args.as_of,
|
||||
"explain_score": args.explain_score,
|
||||
"include_bots": args.include_bots,
|
||||
"all_history": args.all_history,
|
||||
});
|
||||
|
||||
// Resolved/computed values -- what actually ran
|
||||
let resolved_input = serde_json::json!({
|
||||
"mode": run.resolved_input.mode,
|
||||
"project_id": run.resolved_input.project_id,
|
||||
"project_path": run.resolved_input.project_path,
|
||||
"since_ms": run.resolved_input.since_ms,
|
||||
"since_iso": run.resolved_input.since_iso,
|
||||
"since_mode": run.resolved_input.since_mode,
|
||||
"limit": run.resolved_input.limit,
|
||||
});
|
||||
|
||||
let output = WhoJsonEnvelope {
|
||||
ok: true,
|
||||
data: WhoJsonData {
|
||||
mode: mode.to_string(),
|
||||
input,
|
||||
resolved_input,
|
||||
result: data,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
let mut value = serde_json::to_value(&output).unwrap_or_else(|e| {
|
||||
serde_json::json!({"ok":false,"error":{"code":"INTERNAL_ERROR","message":format!("JSON serialization failed: {e}")}})
|
||||
});
|
||||
|
||||
if let Some(f) = &args.fields {
|
||||
let preset_key = format!("who_{mode}");
|
||||
let expanded = crate::cli::robot::expand_fields_preset(f, &preset_key);
|
||||
// Each who mode uses a different array key; try all possible keys
|
||||
for key in &[
|
||||
"experts",
|
||||
"assigned_issues",
|
||||
"authored_mrs",
|
||||
"review_mrs",
|
||||
"categories",
|
||||
"discussions",
|
||||
"users",
|
||||
] {
|
||||
crate::cli::robot::filter_fields(&mut value, key, &expanded);
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::to_string(&value) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WhoJsonEnvelope {
|
||||
ok: bool,
|
||||
data: WhoJsonData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WhoJsonData {
|
||||
mode: String,
|
||||
input: serde_json::Value,
|
||||
resolved_input: serde_json::Value,
|
||||
#[serde(flatten)]
|
||||
result: serde_json::Value,
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../who_tests.rs"]
|
||||
mod tests;
|
||||
323
src/cli/commands/who/overlap.rs
Normal file
323
src/cli/commands/who/overlap.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use crate::core::error::Result;
|
||||
use crate::core::path_resolver::build_path_query;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
pub(super) fn query_overlap(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
project_id: Option<i64>,
|
||||
since_ms: i64,
|
||||
limit: usize,
|
||||
) -> Result<OverlapResult> {
|
||||
let pq = build_path_query(conn, path, project_id)?;
|
||||
|
||||
// Build SQL with 4 signal sources, matching the expert query expansion.
|
||||
// Each row produces (username, role, mr_id, mr_ref, seen_at) for Rust-side accumulation.
|
||||
let path_op = if pq.is_prefix {
|
||||
"LIKE ?1 ESCAPE '\\'"
|
||||
} else {
|
||||
"= ?1"
|
||||
};
|
||||
// Match both new_path and old_path to capture activity on renamed files.
|
||||
// INDEXED BY removed to allow OR across path columns; overlap runs once
|
||||
// per command so the minor plan difference is acceptable.
|
||||
let sql = format!(
|
||||
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||
-- 1. DiffNote reviewer (matches both new_path and old_path)
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND (n.position_new_path {path_op}
|
||||
OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 2. DiffNote MR author (matches both new_path and old_path)
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND (n.position_new_path {path_op}
|
||||
OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
|
||||
AND n.is_system = 0
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 3. MR author via file changes (matches both new_path and old_path)
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(m.updated_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND (fc.new_path {path_op}
|
||||
OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path)
|
||||
SELECT
|
||||
r.username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(m.updated_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
WHERE r.username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND (fc.new_path {path_op}
|
||||
OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
GROUP BY r.username
|
||||
)"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows: Vec<(String, String, u32, i64, Option<String>)> = stmt
|
||||
.query_map(rusqlite::params![pq.value, since_ms, project_id], |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Internal accumulator uses HashSet for MR refs from the start
|
||||
struct OverlapAcc {
|
||||
username: String,
|
||||
author_touch_count: u32,
|
||||
review_touch_count: u32,
|
||||
touch_count: u32,
|
||||
last_seen_at: i64,
|
||||
mr_refs: HashSet<String>,
|
||||
}
|
||||
|
||||
let mut user_map: HashMap<String, OverlapAcc> = HashMap::new();
|
||||
for (username, role, count, last_seen, mr_refs_csv) in &rows {
|
||||
let mr_refs: Vec<String> = mr_refs_csv
|
||||
.as_deref()
|
||||
.map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let entry = user_map
|
||||
.entry(username.clone())
|
||||
.or_insert_with(|| OverlapAcc {
|
||||
username: username.clone(),
|
||||
author_touch_count: 0,
|
||||
review_touch_count: 0,
|
||||
touch_count: 0,
|
||||
last_seen_at: 0,
|
||||
mr_refs: HashSet::new(),
|
||||
});
|
||||
entry.touch_count += count;
|
||||
if role == "author" {
|
||||
entry.author_touch_count += count;
|
||||
} else {
|
||||
entry.review_touch_count += count;
|
||||
}
|
||||
if *last_seen > entry.last_seen_at {
|
||||
entry.last_seen_at = *last_seen;
|
||||
}
|
||||
for r in mr_refs {
|
||||
entry.mr_refs.insert(r);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert accumulators to output structs
|
||||
let mut users: Vec<OverlapUser> = user_map
|
||||
.into_values()
|
||||
.map(|a| {
|
||||
let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
|
||||
mr_refs.sort();
|
||||
let mr_refs_total = mr_refs.len() as u32;
|
||||
let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
|
||||
if mr_refs_truncated {
|
||||
mr_refs.truncate(MAX_MR_REFS_PER_USER);
|
||||
}
|
||||
OverlapUser {
|
||||
username: a.username,
|
||||
author_touch_count: a.author_touch_count,
|
||||
review_touch_count: a.review_touch_count,
|
||||
touch_count: a.touch_count,
|
||||
last_seen_at: a.last_seen_at,
|
||||
mr_refs,
|
||||
mr_refs_total,
|
||||
mr_refs_truncated,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Stable sort with full tie-breakers for deterministic output
|
||||
users.sort_by(|a, b| {
|
||||
b.touch_count
|
||||
.cmp(&a.touch_count)
|
||||
.then_with(|| b.last_seen_at.cmp(&a.last_seen_at))
|
||||
.then_with(|| a.username.cmp(&b.username))
|
||||
});
|
||||
|
||||
let truncated = users.len() > limit;
|
||||
users.truncate(limit);
|
||||
|
||||
Ok(OverlapResult {
|
||||
path_query: if pq.is_prefix {
|
||||
path.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
pq.value.clone()
|
||||
},
|
||||
path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(),
|
||||
users,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format overlap role for display: "A", "R", or "A+R".
|
||||
pub(super) fn format_overlap_role(user: &OverlapUser) -> &'static str {
|
||||
match (user.author_touch_count > 0, user.review_touch_count > 0) {
|
||||
(true, true) => "A+R",
|
||||
(true, false) => "A",
|
||||
(false, true) => "R",
|
||||
(false, false) => "-",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("Overlap for {}", r.path_query))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!(
|
||||
"(matching {} {})",
|
||||
r.path_match,
|
||||
if r.path_match == "exact" {
|
||||
"file"
|
||||
} else {
|
||||
"directory prefix"
|
||||
}
|
||||
))
|
||||
);
|
||||
super::print_scope_hint(project_path);
|
||||
println!();
|
||||
|
||||
if r.users.is_empty() {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No overlapping users found for this path.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
" {:<16} {:<6} {:>7} {:<12} {}",
|
||||
Theme::bold().render("Username"),
|
||||
Theme::bold().render("Role"),
|
||||
Theme::bold().render("MRs"),
|
||||
Theme::bold().render("Last Seen"),
|
||||
Theme::bold().render("MR Refs"),
|
||||
);
|
||||
|
||||
for user in &r.users {
|
||||
let mr_str = user
|
||||
.mr_refs
|
||||
.iter()
|
||||
.take(5)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let overflow = if user.mr_refs.len() > 5 {
|
||||
format!(" +{}", user.mr_refs.len() - 5)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!(
|
||||
" {:<16} {:<6} {:>7} {:<12} {}{}",
|
||||
Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
|
||||
format_overlap_role(user),
|
||||
user.touch_count,
|
||||
render::format_relative_time(user.last_seen_at),
|
||||
mr_str,
|
||||
overflow,
|
||||
);
|
||||
}
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub(super) fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"path_query": r.path_query,
|
||||
"path_match": r.path_match,
|
||||
"truncated": r.truncated,
|
||||
"users": r.users.iter().map(|u| serde_json::json!({
|
||||
"username": u.username,
|
||||
"role": format_overlap_role(u),
|
||||
"author_touch_count": u.author_touch_count,
|
||||
"review_touch_count": u.review_touch_count,
|
||||
"touch_count": u.touch_count,
|
||||
"last_seen_at": ms_to_iso(u.last_seen_at),
|
||||
"mr_refs": u.mr_refs,
|
||||
"mr_refs_total": u.mr_refs_total,
|
||||
"mr_refs_truncated": u.mr_refs_truncated,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
214
src/cli/commands/who/reviews.rs
Normal file
214
src/cli/commands/who/reviews.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::core::error::Result;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
// ─── Query: Reviews Mode ────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn query_reviews(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
project_id: Option<i64>,
|
||||
since_ms: i64,
|
||||
) -> Result<ReviewsResult> {
|
||||
// Force the partial index on DiffNote queries (same rationale as expert mode).
|
||||
// COUNT + COUNT(DISTINCT) + category extraction all benefit from 26K DiffNote
|
||||
// scan vs 282K notes full scan: measured 25x speedup.
|
||||
let total_sql = "SELECT COUNT(*) FROM notes n
|
||||
INDEXED BY idx_notes_diffnote_path_created
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.author_username = ?1
|
||||
AND n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND (m.author_username IS NULL OR m.author_username != ?1)
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)";
|
||||
|
||||
let total_diffnotes: u32 = conn.query_row(
|
||||
total_sql,
|
||||
rusqlite::params![username, since_ms, project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Count distinct MRs reviewed
|
||||
let mrs_sql = "SELECT COUNT(DISTINCT m.id) FROM notes n
|
||||
INDEXED BY idx_notes_diffnote_path_created
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.author_username = ?1
|
||||
AND n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND (m.author_username IS NULL OR m.author_username != ?1)
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)";
|
||||
|
||||
let mrs_reviewed: u32 = conn.query_row(
|
||||
mrs_sql,
|
||||
rusqlite::params![username, since_ms, project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Extract prefixed categories: body starts with **prefix**
|
||||
let cat_sql = "SELECT
|
||||
SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
|
||||
COUNT(*) AS cnt
|
||||
FROM notes n INDEXED BY idx_notes_diffnote_path_created
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.author_username = ?1
|
||||
AND n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND (m.author_username IS NULL OR m.author_username != ?1)
|
||||
AND ltrim(n.body) LIKE '**%**%'
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY raw_prefix
|
||||
ORDER BY cnt DESC";
|
||||
|
||||
let mut stmt = conn.prepare_cached(cat_sql)?;
|
||||
let raw_categories: Vec<(String, u32)> = stmt
|
||||
.query_map(rusqlite::params![username, since_ms, project_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get(1)?))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Normalize categories: lowercase, strip trailing colon/space,
|
||||
// merge nit/nitpick variants, merge (non-blocking) variants
|
||||
let mut merged: HashMap<String, u32> = HashMap::new();
|
||||
for (raw, count) in &raw_categories {
|
||||
let normalized = normalize_review_prefix(raw);
|
||||
if !normalized.is_empty() {
|
||||
*merged.entry(normalized).or_insert(0) += count;
|
||||
}
|
||||
}
|
||||
|
||||
let categorized_count: u32 = merged.values().sum();
|
||||
|
||||
let mut categories: Vec<ReviewCategory> = merged
|
||||
.into_iter()
|
||||
.map(|(name, count)| {
|
||||
let percentage = if categorized_count > 0 {
|
||||
f64::from(count) / f64::from(categorized_count) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ReviewCategory {
|
||||
name,
|
||||
count,
|
||||
percentage,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
categories.sort_by_key(|b| std::cmp::Reverse(b.count));
|
||||
|
||||
Ok(ReviewsResult {
|
||||
username: username.to_string(),
|
||||
total_diffnotes,
|
||||
categorized_count,
|
||||
mrs_reviewed,
|
||||
categories,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalize a raw review prefix like "Suggestion (non-blocking):" into "suggestion".
|
||||
pub(super) fn normalize_review_prefix(raw: &str) -> String {
|
||||
let s = raw.trim().trim_end_matches(':').trim().to_lowercase();
|
||||
|
||||
// Strip "(non-blocking)" and similar parentheticals
|
||||
let s = if let Some(idx) = s.find('(') {
|
||||
s[..idx].trim().to_string()
|
||||
} else {
|
||||
s
|
||||
};
|
||||
|
||||
// Merge nit/nitpick variants
|
||||
match s.as_str() {
|
||||
"nitpick" | "nit" => "nit".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Human Renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn print_reviews_human(r: &ReviewsResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!(
|
||||
"{} {} -- Review Patterns",
|
||||
Icons::user(),
|
||||
r.username
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!();
|
||||
|
||||
if r.total_diffnotes == 0 {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No review comments found for this user.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} DiffNotes across {} MRs ({} categorized)",
|
||||
Theme::bold().render(&r.total_diffnotes.to_string()),
|
||||
Theme::bold().render(&r.mrs_reviewed.to_string()),
|
||||
Theme::bold().render(&r.categorized_count.to_string()),
|
||||
);
|
||||
println!();
|
||||
|
||||
if !r.categories.is_empty() {
|
||||
println!(
|
||||
" {:<16} {:>6} {:>6}",
|
||||
Theme::bold().render("Category"),
|
||||
Theme::bold().render("Count"),
|
||||
Theme::bold().render("%"),
|
||||
);
|
||||
|
||||
for cat in &r.categories {
|
||||
println!(
|
||||
" {:<16} {:>6} {:>5.1}%",
|
||||
Theme::info().render(&cat.name),
|
||||
cat.count,
|
||||
cat.percentage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let uncategorized = r.total_diffnotes - r.categorized_count;
|
||||
if uncategorized > 0 {
|
||||
println!();
|
||||
println!(
|
||||
" {} {} uncategorized (no **prefix** convention)",
|
||||
Theme::dim().render("Note:"),
|
||||
uncategorized,
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
// ─── Robot Renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn reviews_to_json(r: &ReviewsResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"username": r.username,
|
||||
"total_diffnotes": r.total_diffnotes,
|
||||
"categorized_count": r.categorized_count,
|
||||
"mrs_reviewed": r.mrs_reviewed,
|
||||
"categories": r.categories.iter().map(|c| serde_json::json!({
|
||||
"name": c.name,
|
||||
"count": c.count,
|
||||
"percentage": (c.percentage * 10.0).round() / 10.0,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
185
src/cli/commands/who/types.rs
Normal file
185
src/cli/commands/who/types.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
// ─── Result Types ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All pub result structs and enums for the `who` command family.
|
||||
// Zero logic — pure data definitions.
|
||||
|
||||
/// Top-level run result: carries resolved inputs + the mode-specific result.
|
||||
pub struct WhoRun {
|
||||
pub resolved_input: WhoResolvedInput,
|
||||
pub result: WhoResult,
|
||||
}
|
||||
|
||||
/// Resolved query parameters -- computed once, used for robot JSON reproducibility.
|
||||
pub struct WhoResolvedInput {
|
||||
pub mode: String,
|
||||
pub project_id: Option<i64>,
|
||||
pub project_path: Option<String>,
|
||||
pub since_ms: Option<i64>,
|
||||
pub since_iso: Option<String>,
|
||||
/// "default" (mode default applied), "explicit" (user provided --since), "none" (no window)
|
||||
pub since_mode: String,
|
||||
pub limit: Option<u16>,
|
||||
}
|
||||
|
||||
/// Top-level result enum -- one variant per mode.
|
||||
pub enum WhoResult {
|
||||
Expert(ExpertResult),
|
||||
Workload(WorkloadResult),
|
||||
Reviews(ReviewsResult),
|
||||
Active(ActiveResult),
|
||||
Overlap(OverlapResult),
|
||||
}
|
||||
|
||||
// --- Expert ---
|
||||
|
||||
pub struct ExpertResult {
|
||||
pub path_query: String,
|
||||
/// "exact" or "prefix" -- how the path was matched in SQL.
|
||||
pub path_match: String,
|
||||
pub experts: Vec<Expert>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub struct Expert {
|
||||
pub username: String,
|
||||
pub score: i64,
|
||||
/// Unrounded f64 score (only populated when explain_score is set).
|
||||
pub score_raw: Option<f64>,
|
||||
/// Per-component score breakdown (only populated when explain_score is set).
|
||||
pub components: Option<ScoreComponents>,
|
||||
pub review_mr_count: u32,
|
||||
pub review_note_count: u32,
|
||||
pub author_mr_count: u32,
|
||||
pub last_seen_ms: i64,
|
||||
/// Stable MR references like "group/project!123"
|
||||
pub mr_refs: Vec<String>,
|
||||
pub mr_refs_total: u32,
|
||||
pub mr_refs_truncated: bool,
|
||||
/// Per-MR detail breakdown (only populated when --detail is set)
|
||||
pub details: Option<Vec<ExpertMrDetail>>,
|
||||
}
|
||||
|
||||
/// Per-component score breakdown for explain mode.
|
||||
pub struct ScoreComponents {
|
||||
pub author: f64,
|
||||
pub reviewer_participated: f64,
|
||||
pub reviewer_assigned: f64,
|
||||
pub notes: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExpertMrDetail {
|
||||
pub mr_ref: String,
|
||||
pub title: String,
|
||||
/// "R", "A", or "A+R"
|
||||
pub role: String,
|
||||
pub note_count: u32,
|
||||
pub last_activity_ms: i64,
|
||||
}
|
||||
|
||||
// --- Workload ---
|
||||
|
||||
pub struct WorkloadResult {
|
||||
pub username: String,
|
||||
pub assigned_issues: Vec<WorkloadIssue>,
|
||||
pub authored_mrs: Vec<WorkloadMr>,
|
||||
pub reviewing_mrs: Vec<WorkloadMr>,
|
||||
pub unresolved_discussions: Vec<WorkloadDiscussion>,
|
||||
pub assigned_issues_truncated: bool,
|
||||
pub authored_mrs_truncated: bool,
|
||||
pub reviewing_mrs_truncated: bool,
|
||||
pub unresolved_discussions_truncated: bool,
|
||||
}
|
||||
|
||||
pub struct WorkloadIssue {
|
||||
pub iid: i64,
|
||||
/// Canonical reference: `group/project#iid`
|
||||
pub ref_: String,
|
||||
pub title: String,
|
||||
pub project_path: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
pub struct WorkloadMr {
|
||||
pub iid: i64,
|
||||
/// Canonical reference: `group/project!iid`
|
||||
pub ref_: String,
|
||||
pub title: String,
|
||||
pub draft: bool,
|
||||
pub project_path: String,
|
||||
pub author_username: Option<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
pub struct WorkloadDiscussion {
|
||||
pub entity_type: String,
|
||||
pub entity_iid: i64,
|
||||
/// Canonical reference: `group/project!iid` or `group/project#iid`
|
||||
pub ref_: String,
|
||||
pub entity_title: String,
|
||||
pub project_path: String,
|
||||
pub last_note_at: i64,
|
||||
}
|
||||
|
||||
// --- Reviews ---
|
||||
|
||||
pub struct ReviewsResult {
|
||||
pub username: String,
|
||||
pub total_diffnotes: u32,
|
||||
pub categorized_count: u32,
|
||||
pub mrs_reviewed: u32,
|
||||
pub categories: Vec<ReviewCategory>,
|
||||
}
|
||||
|
||||
pub struct ReviewCategory {
|
||||
pub name: String,
|
||||
pub count: u32,
|
||||
pub percentage: f64,
|
||||
}
|
||||
|
||||
// --- Active ---
|
||||
|
||||
pub struct ActiveResult {
|
||||
pub discussions: Vec<ActiveDiscussion>,
|
||||
/// Count of unresolved discussions *within the time window*, not total across all time.
|
||||
pub total_unresolved_in_window: u32,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub struct ActiveDiscussion {
|
||||
pub discussion_id: i64,
|
||||
pub entity_type: String,
|
||||
pub entity_iid: i64,
|
||||
pub entity_title: String,
|
||||
pub project_path: String,
|
||||
pub last_note_at: i64,
|
||||
pub note_count: u32,
|
||||
pub participants: Vec<String>,
|
||||
pub participants_total: u32,
|
||||
pub participants_truncated: bool,
|
||||
}
|
||||
|
||||
// --- Overlap ---
|
||||
|
||||
pub struct OverlapResult {
|
||||
pub path_query: String,
|
||||
/// "exact" or "prefix" -- how the path was matched in SQL.
|
||||
pub path_match: String,
|
||||
pub users: Vec<OverlapUser>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub struct OverlapUser {
|
||||
pub username: String,
|
||||
pub author_touch_count: u32,
|
||||
pub review_touch_count: u32,
|
||||
pub touch_count: u32,
|
||||
pub last_seen_at: i64,
|
||||
/// Stable MR references like "group/project!123"
|
||||
pub mr_refs: Vec<String>,
|
||||
pub mr_refs_total: u32,
|
||||
pub mr_refs_truncated: bool,
|
||||
}
|
||||
|
||||
/// Maximum MR references to retain per user in output (shared across modes).
|
||||
pub const MAX_MR_REFS_PER_USER: usize = 50;
|
||||
372
src/cli/commands/who/workload.rs
Normal file
372
src/cli/commands/who/workload.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use crate::core::error::Result;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
// ─── Query: Workload Mode ───────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn query_workload(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
project_id: Option<i64>,
|
||||
since_ms: Option<i64>,
|
||||
limit: usize,
|
||||
include_closed: bool,
|
||||
) -> Result<WorkloadResult> {
|
||||
// Prevent overflow: saturating_add caps at usize::MAX instead of wrapping to 0.
|
||||
// The .min() ensures the value fits in i64 for SQLite's LIMIT clause.
|
||||
let limit_plus_one = limit.saturating_add(1).min(i64::MAX as usize) as i64;
|
||||
|
||||
// Query 1: Open issues assigned to user
|
||||
let issues_sql = "SELECT i.iid,
|
||||
(p.path_with_namespace || '#' || i.iid) AS ref,
|
||||
i.title, p.path_with_namespace, i.updated_at
|
||||
FROM issues i
|
||||
JOIN issue_assignees ia ON ia.issue_id = i.id
|
||||
JOIN projects p ON i.project_id = p.id
|
||||
WHERE ia.username = ?1
|
||||
AND i.state = 'opened'
|
||||
AND (?2 IS NULL OR i.project_id = ?2)
|
||||
AND (?3 IS NULL OR i.updated_at >= ?3)
|
||||
ORDER BY i.updated_at DESC
|
||||
LIMIT ?4";
|
||||
|
||||
let mut stmt = conn.prepare_cached(issues_sql)?;
|
||||
let assigned_issues: Vec<WorkloadIssue> = stmt
|
||||
.query_map(
|
||||
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
||||
|row| {
|
||||
Ok(WorkloadIssue {
|
||||
iid: row.get(0)?,
|
||||
ref_: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
project_path: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Query 2: Open MRs authored
|
||||
let authored_sql = "SELECT m.iid,
|
||||
(p.path_with_namespace || '!' || m.iid) AS ref,
|
||||
m.title, m.draft, p.path_with_namespace, m.updated_at
|
||||
FROM merge_requests m
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.author_username = ?1
|
||||
AND m.state = 'opened'
|
||||
AND (?2 IS NULL OR m.project_id = ?2)
|
||||
AND (?3 IS NULL OR m.updated_at >= ?3)
|
||||
ORDER BY m.updated_at DESC
|
||||
LIMIT ?4";
|
||||
let mut stmt = conn.prepare_cached(authored_sql)?;
|
||||
let authored_mrs: Vec<WorkloadMr> = stmt
|
||||
.query_map(
|
||||
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
||||
|row| {
|
||||
Ok(WorkloadMr {
|
||||
iid: row.get(0)?,
|
||||
ref_: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
draft: row.get::<_, i32>(3)? != 0,
|
||||
project_path: row.get(4)?,
|
||||
author_username: None,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Query 3: Open MRs where user is reviewer
|
||||
let reviewing_sql = "SELECT m.iid,
|
||||
(p.path_with_namespace || '!' || m.iid) AS ref,
|
||||
m.title, m.draft, p.path_with_namespace,
|
||||
m.author_username, m.updated_at
|
||||
FROM merge_requests m
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE r.username = ?1
|
||||
AND m.state = 'opened'
|
||||
AND (?2 IS NULL OR m.project_id = ?2)
|
||||
AND (?3 IS NULL OR m.updated_at >= ?3)
|
||||
ORDER BY m.updated_at DESC
|
||||
LIMIT ?4";
|
||||
let mut stmt = conn.prepare_cached(reviewing_sql)?;
|
||||
let reviewing_mrs: Vec<WorkloadMr> = stmt
|
||||
.query_map(
|
||||
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
||||
|row| {
|
||||
Ok(WorkloadMr {
|
||||
iid: row.get(0)?,
|
||||
ref_: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
draft: row.get::<_, i32>(3)? != 0,
|
||||
project_path: row.get(4)?,
|
||||
author_username: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Query 4: Unresolved discussions where user participated
|
||||
let state_filter = if include_closed {
|
||||
""
|
||||
} else {
|
||||
" AND (i.id IS NULL OR i.state = 'opened')
|
||||
AND (m.id IS NULL OR m.state = 'opened')"
|
||||
};
|
||||
let disc_sql = format!(
|
||||
"SELECT d.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS entity_iid,
|
||||
(p.path_with_namespace ||
|
||||
CASE WHEN d.noteable_type = 'MergeRequest' THEN '!' ELSE '#' END ||
|
||||
COALESCE(i.iid, m.iid)) AS ref,
|
||||
COALESCE(i.title, m.title) AS entity_title,
|
||||
p.path_with_namespace,
|
||||
d.last_note_at
|
||||
FROM discussions d
|
||||
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 d.resolvable = 1 AND d.resolved = 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM notes n
|
||||
WHERE n.discussion_id = d.id
|
||||
AND n.author_username = ?1
|
||||
AND n.is_system = 0
|
||||
)
|
||||
AND (?2 IS NULL OR d.project_id = ?2)
|
||||
AND (?3 IS NULL OR d.last_note_at >= ?3)
|
||||
{state_filter}
|
||||
ORDER BY d.last_note_at DESC
|
||||
LIMIT ?4"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare_cached(&disc_sql)?;
|
||||
let unresolved_discussions: Vec<WorkloadDiscussion> = stmt
|
||||
.query_map(
|
||||
rusqlite::params![username, project_id, since_ms, limit_plus_one],
|
||||
|row| {
|
||||
let noteable_type: String = row.get(0)?;
|
||||
let entity_type = if noteable_type == "MergeRequest" {
|
||||
"MR"
|
||||
} else {
|
||||
"Issue"
|
||||
};
|
||||
Ok(WorkloadDiscussion {
|
||||
entity_type: entity_type.to_string(),
|
||||
entity_iid: row.get(1)?,
|
||||
ref_: row.get(2)?,
|
||||
entity_title: row.get(3)?,
|
||||
project_path: row.get(4)?,
|
||||
last_note_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Truncation detection
|
||||
let assigned_issues_truncated = assigned_issues.len() > limit;
|
||||
let authored_mrs_truncated = authored_mrs.len() > limit;
|
||||
let reviewing_mrs_truncated = reviewing_mrs.len() > limit;
|
||||
let unresolved_discussions_truncated = unresolved_discussions.len() > limit;
|
||||
|
||||
let assigned_issues: Vec<WorkloadIssue> = assigned_issues.into_iter().take(limit).collect();
|
||||
let authored_mrs: Vec<WorkloadMr> = authored_mrs.into_iter().take(limit).collect();
|
||||
let reviewing_mrs: Vec<WorkloadMr> = reviewing_mrs.into_iter().take(limit).collect();
|
||||
let unresolved_discussions: Vec<WorkloadDiscussion> =
|
||||
unresolved_discussions.into_iter().take(limit).collect();
|
||||
|
||||
Ok(WorkloadResult {
|
||||
username: username.to_string(),
|
||||
assigned_issues,
|
||||
authored_mrs,
|
||||
reviewing_mrs,
|
||||
unresolved_discussions,
|
||||
assigned_issues_truncated,
|
||||
authored_mrs_truncated,
|
||||
reviewing_mrs_truncated,
|
||||
unresolved_discussions_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Human Renderer: Workload ───────────────────────────────────────────────
|
||||
|
||||
pub(super) fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!(
|
||||
"{} {} -- Workload Summary",
|
||||
Icons::user(),
|
||||
r.username
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
|
||||
if !r.assigned_issues.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
|
||||
);
|
||||
for item in &r.assigned_issues {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
Theme::info().render(&item.ref_),
|
||||
render::truncate(&item.title, 40),
|
||||
Theme::dim().render(&render::format_relative_time(item.updated_at)),
|
||||
);
|
||||
}
|
||||
if r.assigned_issues_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !r.authored_mrs.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
|
||||
);
|
||||
for mr in &r.authored_mrs {
|
||||
let draft = if mr.draft { " [draft]" } else { "" };
|
||||
println!(
|
||||
" {} {}{} {}",
|
||||
Theme::info().render(&mr.ref_),
|
||||
render::truncate(&mr.title, 35),
|
||||
Theme::dim().render(draft),
|
||||
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
|
||||
);
|
||||
}
|
||||
if r.authored_mrs_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !r.reviewing_mrs.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
|
||||
);
|
||||
for mr in &r.reviewing_mrs {
|
||||
let author = mr
|
||||
.author_username
|
||||
.as_deref()
|
||||
.map(|a| format!(" by @{a}"))
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
" {} {}{} {}",
|
||||
Theme::info().render(&mr.ref_),
|
||||
render::truncate(&mr.title, 30),
|
||||
Theme::dim().render(&author),
|
||||
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
|
||||
);
|
||||
}
|
||||
if r.reviewing_mrs_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !r.unresolved_discussions.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
render::section_divider(&format!(
|
||||
"Unresolved Discussions ({})",
|
||||
r.unresolved_discussions.len()
|
||||
))
|
||||
);
|
||||
for disc in &r.unresolved_discussions {
|
||||
println!(
|
||||
" {} {} {} {}",
|
||||
Theme::dim().render(&disc.entity_type),
|
||||
Theme::info().render(&disc.ref_),
|
||||
render::truncate(&disc.entity_title, 35),
|
||||
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
|
||||
);
|
||||
}
|
||||
if r.unresolved_discussions_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if r.assigned_issues.is_empty()
|
||||
&& r.authored_mrs.is_empty()
|
||||
&& r.reviewing_mrs.is_empty()
|
||||
&& r.unresolved_discussions.is_empty()
|
||||
{
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No open work items found for this user.")
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
// ─── JSON Renderer: Workload ────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn workload_to_json(r: &WorkloadResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"username": r.username,
|
||||
"assigned_issues": r.assigned_issues.iter().map(|i| serde_json::json!({
|
||||
"iid": i.iid,
|
||||
"ref": i.ref_,
|
||||
"title": i.title,
|
||||
"project_path": i.project_path,
|
||||
"updated_at": ms_to_iso(i.updated_at),
|
||||
})).collect::<Vec<_>>(),
|
||||
"authored_mrs": r.authored_mrs.iter().map(|m| serde_json::json!({
|
||||
"iid": m.iid,
|
||||
"ref": m.ref_,
|
||||
"title": m.title,
|
||||
"draft": m.draft,
|
||||
"project_path": m.project_path,
|
||||
"updated_at": ms_to_iso(m.updated_at),
|
||||
})).collect::<Vec<_>>(),
|
||||
"reviewing_mrs": r.reviewing_mrs.iter().map(|m| serde_json::json!({
|
||||
"iid": m.iid,
|
||||
"ref": m.ref_,
|
||||
"title": m.title,
|
||||
"draft": m.draft,
|
||||
"project_path": m.project_path,
|
||||
"author_username": m.author_username,
|
||||
"updated_at": ms_to_iso(m.updated_at),
|
||||
})).collect::<Vec<_>>(),
|
||||
"unresolved_discussions": r.unresolved_discussions.iter().map(|d| serde_json::json!({
|
||||
"entity_type": d.entity_type,
|
||||
"entity_iid": d.entity_iid,
|
||||
"ref": d.ref_,
|
||||
"entity_title": d.entity_title,
|
||||
"project_path": d.project_path,
|
||||
"last_note_at": ms_to_iso(d.last_note_at),
|
||||
})).collect::<Vec<_>>(),
|
||||
"summary": {
|
||||
"assigned_issue_count": r.assigned_issues.len(),
|
||||
"authored_mr_count": r.authored_mrs.len(),
|
||||
"reviewing_mr_count": r.reviewing_mrs.len(),
|
||||
"unresolved_discussion_count": r.unresolved_discussions.len(),
|
||||
},
|
||||
"truncation": {
|
||||
"assigned_issues_truncated": r.assigned_issues_truncated,
|
||||
"authored_mrs_truncated": r.authored_mrs_truncated,
|
||||
"reviewing_mrs_truncated": r.reviewing_mrs_truncated,
|
||||
"unresolved_discussions_truncated": r.unresolved_discussions_truncated,
|
||||
}
|
||||
})
|
||||
}
|
||||
3431
src/cli/commands/who_tests.rs
Normal file
3431
src/cli/commands/who_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
479
src/cli/mod.rs
479
src/cli/mod.rs
@@ -1,20 +1,24 @@
|
||||
pub mod autocorrect;
|
||||
pub mod commands;
|
||||
pub mod progress;
|
||||
pub mod render;
|
||||
pub mod robot;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::io::IsTerminal;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "lore")]
|
||||
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
|
||||
#[command(subcommand_required = false)]
|
||||
#[command(infer_subcommands = true)]
|
||||
#[command(after_long_help = "\x1b[1mEnvironment:\x1b[0m
|
||||
GITLAB_TOKEN GitLab personal access token (or name set in config)
|
||||
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
|
||||
LORE_CONFIG_PATH Override config file location
|
||||
NO_COLOR Disable color output (any non-empty value)")]
|
||||
NO_COLOR Disable color output (any non-empty value)
|
||||
LORE_ICONS Override icon set: nerd, unicode, or ascii
|
||||
NERD_FONTS Enable Nerd Font icons when set to a non-empty value")]
|
||||
pub struct Cli {
|
||||
/// Path to config file
|
||||
#[arg(short = 'c', long, global = true, help = "Path to config file")]
|
||||
@@ -42,6 +46,10 @@ pub struct Cli {
|
||||
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
||||
pub color: String,
|
||||
|
||||
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
|
||||
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
|
||||
pub icons: Option<String>,
|
||||
|
||||
/// Suppress non-essential output
|
||||
#[arg(
|
||||
short = 'q',
|
||||
@@ -107,11 +115,21 @@ impl Cli {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Commands {
|
||||
/// List or show issues
|
||||
#[command(visible_alias = "issue")]
|
||||
Issues(IssuesArgs),
|
||||
|
||||
/// List or show merge requests
|
||||
#[command(
|
||||
visible_alias = "mr",
|
||||
alias = "merge-requests",
|
||||
alias = "merge-request"
|
||||
)]
|
||||
Mrs(MrsArgs),
|
||||
|
||||
/// List notes from discussions
|
||||
#[command(visible_alias = "note")]
|
||||
Notes(NotesArgs),
|
||||
|
||||
/// Ingest data from GitLab
|
||||
Ingest(IngestArgs),
|
||||
|
||||
@@ -119,18 +137,35 @@ pub enum Commands {
|
||||
Count(CountArgs),
|
||||
|
||||
/// Show sync state
|
||||
#[command(
|
||||
visible_alias = "st",
|
||||
after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore status # Show last sync times per project
|
||||
lore --robot status # JSON output for automation"
|
||||
)]
|
||||
Status,
|
||||
|
||||
/// Verify GitLab authentication
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore auth # Verify token and show user info
|
||||
lore --robot auth # JSON output for automation")]
|
||||
Auth,
|
||||
|
||||
/// Check environment health
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore doctor # Check config, token, database, Ollama
|
||||
lore --robot doctor # JSON output for automation")]
|
||||
Doctor,
|
||||
|
||||
/// Show version information
|
||||
Version,
|
||||
|
||||
/// Initialize configuration and database
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore init # Interactive setup
|
||||
lore init --force # Overwrite existing config
|
||||
lore --robot init --gitlab-url https://gitlab.com \\
|
||||
--token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")]
|
||||
Init {
|
||||
/// Skip overwrite confirmation
|
||||
#[arg(short = 'f', long)]
|
||||
@@ -157,19 +192,24 @@ pub enum Commands {
|
||||
default_project: Option<String>,
|
||||
},
|
||||
|
||||
/// Back up local database (not yet implemented)
|
||||
#[command(hide = true)]
|
||||
Backup,
|
||||
|
||||
/// Reset local database (not yet implemented)
|
||||
#[command(hide = true)]
|
||||
Reset {
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Search indexed documents
|
||||
#[command(visible_alias = "find", alias = "query")]
|
||||
Search(SearchArgs),
|
||||
|
||||
/// Show document and index statistics
|
||||
#[command(visible_alias = "stat")]
|
||||
Stats(StatsArgs),
|
||||
|
||||
/// Generate searchable documents from ingested data
|
||||
@@ -183,9 +223,15 @@ pub enum Commands {
|
||||
Sync(SyncArgs),
|
||||
|
||||
/// Run pending database migrations
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore migrate # Apply pending migrations
|
||||
lore --robot migrate # JSON output for automation")]
|
||||
Migrate,
|
||||
|
||||
/// Quick health check: config, database, schema version
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore health # Quick pre-flight check (exit 0 = healthy)
|
||||
lore --robot health # JSON output for automation")]
|
||||
Health,
|
||||
|
||||
/// Machine-readable command manifest for agent self-discovery
|
||||
@@ -215,7 +261,21 @@ 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),
|
||||
|
||||
/// Trace why code was introduced: file -> MR -> issue -> discussion
|
||||
Trace(TraceArgs),
|
||||
|
||||
/// Detect discussion divergence from original intent
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore drift issues 42 # Check drift on issue #42
|
||||
lore drift issues 42 --threshold 0.3 # Custom similarity threshold
|
||||
lore --robot drift issues 42 -p group/repo # JSON output, scoped to project")]
|
||||
Drift {
|
||||
/// Entity type (currently only "issues" supported)
|
||||
#[arg(value_parser = ["issues"])]
|
||||
@@ -233,6 +293,45 @@ pub enum Commands {
|
||||
project: Option<String>,
|
||||
},
|
||||
|
||||
/// Find semantically related entities via vector search
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore related issues 42 # Find entities related to issue #42
|
||||
lore related mrs 99 -p group/repo # Related to MR #99 in specific project
|
||||
lore related 'authentication flow' # Find entities matching free text query
|
||||
lore --robot related issues 42 -n 5 # JSON output, limit 5 results")]
|
||||
Related {
|
||||
/// Entity type (issues, mrs) or free text query
|
||||
query_or_type: String,
|
||||
|
||||
/// Entity IID (required when first arg is entity type)
|
||||
iid: Option<i64>,
|
||||
|
||||
/// Maximum results
|
||||
#[arg(short = 'n', long, default_value = "10")]
|
||||
limit: usize,
|
||||
|
||||
/// Scope to project (fuzzy match)
|
||||
#[arg(short, long)]
|
||||
project: Option<String>,
|
||||
},
|
||||
|
||||
/// Manage cron-based automatic syncing
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore cron install # Install cron job (every 8 minutes)
|
||||
lore cron install --interval 15 # Custom interval
|
||||
lore cron status # Check if cron is installed
|
||||
lore cron uninstall # Remove cron job")]
|
||||
Cron(CronArgs),
|
||||
|
||||
/// Manage stored GitLab token
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore token set # Interactive token entry + validation
|
||||
lore token set --token glpat-xxx # Non-interactive token storage
|
||||
echo glpat-xxx | lore token set # Pipe token from stdin
|
||||
lore token show # Show token (masked)
|
||||
lore token show --unmask # Show full token")]
|
||||
Token(TokenArgs),
|
||||
|
||||
#[command(hide = true)]
|
||||
List {
|
||||
#[arg(value_parser = ["issues", "mrs"])]
|
||||
@@ -318,7 +417,7 @@ pub struct IssuesArgs {
|
||||
pub fields: Option<Vec<String>>,
|
||||
|
||||
/// Filter by state (opened, closed, all)
|
||||
#[arg(short = 's', long, help_heading = "Filters")]
|
||||
#[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "closed", "all"])]
|
||||
pub state: Option<String>,
|
||||
|
||||
/// Filter by project path
|
||||
@@ -412,7 +511,7 @@ pub struct MrsArgs {
|
||||
pub fields: Option<Vec<String>>,
|
||||
|
||||
/// Filter by state (opened, merged, closed, locked, all)
|
||||
#[arg(short = 's', long, help_heading = "Filters")]
|
||||
#[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "merged", "closed", "locked", "all"])]
|
||||
pub state: Option<String>,
|
||||
|
||||
/// Filter by project path
|
||||
@@ -489,6 +588,104 @@ pub struct MrsArgs {
|
||||
pub no_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore notes # List 50 most recent notes
|
||||
lore notes --author alice --since 7d # Notes by alice in last 7 days
|
||||
lore notes --for-issue 42 -p group/repo # Notes on issue #42
|
||||
lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/")]
|
||||
pub struct NotesArgs {
|
||||
/// Maximum results
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long = "limit",
|
||||
default_value = "50",
|
||||
help_heading = "Output"
|
||||
)]
|
||||
pub limit: usize,
|
||||
|
||||
/// Select output fields (comma-separated, or 'minimal' preset: id,author_username,body,created_at_iso)
|
||||
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||
pub fields: Option<Vec<String>>,
|
||||
|
||||
/// Filter by author username
|
||||
#[arg(short = 'a', long, help_heading = "Filters")]
|
||||
pub author: Option<String>,
|
||||
|
||||
/// Filter by note type (DiffNote, DiscussionNote)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub note_type: Option<String>,
|
||||
|
||||
/// Filter by body text (substring match)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub contains: Option<String>,
|
||||
|
||||
/// Filter by internal note ID
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub note_id: Option<i64>,
|
||||
|
||||
/// Filter by GitLab note ID
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub gitlab_note_id: Option<i64>,
|
||||
|
||||
/// Filter by discussion ID
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub discussion_id: Option<String>,
|
||||
|
||||
/// Include system notes (excluded by default)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub include_system: bool,
|
||||
|
||||
/// Filter to notes on a specific issue IID (requires --project or default_project)
|
||||
#[arg(long, conflicts_with = "for_mr", help_heading = "Filters")]
|
||||
pub for_issue: Option<i64>,
|
||||
|
||||
/// Filter to notes on a specific MR IID (requires --project or default_project)
|
||||
#[arg(long, conflicts_with = "for_issue", help_heading = "Filters")]
|
||||
pub for_mr: Option<i64>,
|
||||
|
||||
/// Filter by project path
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub since: Option<String>,
|
||||
|
||||
/// Filter until date (YYYY-MM-DD, inclusive end-of-day)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub until: Option<String>,
|
||||
|
||||
/// Filter by file path (exact match or prefix with trailing /)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub path: Option<String>,
|
||||
|
||||
/// Filter by resolution status (any, unresolved, resolved)
|
||||
#[arg(
|
||||
long,
|
||||
value_parser = ["any", "unresolved", "resolved"],
|
||||
help_heading = "Filters"
|
||||
)]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Sort field (created, updated)
|
||||
#[arg(
|
||||
long,
|
||||
value_parser = ["created", "updated"],
|
||||
default_value = "created",
|
||||
help_heading = "Sorting"
|
||||
)]
|
||||
pub sort: String,
|
||||
|
||||
/// Sort ascending (default: descending)
|
||||
#[arg(long, help_heading = "Sorting")]
|
||||
pub asc: bool,
|
||||
|
||||
/// Open first matching item in browser
|
||||
#[arg(long, help_heading = "Actions")]
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct IngestArgs {
|
||||
/// Entity to ingest (issues, mrs). Omit to ingest everything
|
||||
@@ -522,6 +719,11 @@ pub struct IngestArgs {
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore stats # Show document and index statistics
|
||||
lore stats --check # Run integrity checks
|
||||
lore stats --repair --dry-run # Preview what repair would fix
|
||||
lore --robot stats # JSON output for automation")]
|
||||
pub struct StatsArgs {
|
||||
/// Run integrity checks
|
||||
#[arg(long, overrides_with = "no_check")]
|
||||
@@ -556,8 +758,8 @@ pub struct SearchArgs {
|
||||
#[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Mode")]
|
||||
pub mode: String,
|
||||
|
||||
/// Filter by source type (issue, mr, discussion)
|
||||
#[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")]
|
||||
/// Filter by source type (issue, mr, discussion, note)
|
||||
#[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion", "note"], help_heading = "Filters")]
|
||||
pub source_type: Option<String>,
|
||||
|
||||
/// Filter by author username
|
||||
@@ -610,6 +812,10 @@ pub struct SearchArgs {
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore generate-docs # Generate docs for dirty entities
|
||||
lore generate-docs --full # Full rebuild of all documents
|
||||
lore generate-docs --full -p group/repo # Full rebuild for one project")]
|
||||
pub struct GenerateDocsArgs {
|
||||
/// Full rebuild: seed all entities into dirty queue, then drain
|
||||
#[arg(long)]
|
||||
@@ -624,8 +830,11 @@ pub struct GenerateDocsArgs {
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore sync # Full pipeline: ingest + docs + embed
|
||||
lore sync --no-embed # Skip embedding step
|
||||
lore sync --no-status # Skip work-item status enrichment
|
||||
lore sync --full --force # Full re-sync, override stale lock
|
||||
lore sync --dry-run # Preview what would change")]
|
||||
lore sync --dry-run # Preview what would change
|
||||
lore sync --issue 42 -p group/repo # Surgically sync one issue
|
||||
lore sync --mr 10 --mr 20 -p g/r # Surgically sync two MRs")]
|
||||
pub struct SyncArgs {
|
||||
/// Reset cursors, fetch everything
|
||||
#[arg(long, overrides_with = "no_full")]
|
||||
@@ -657,15 +866,47 @@ pub struct SyncArgs {
|
||||
#[arg(long = "no-file-changes")]
|
||||
pub no_file_changes: bool,
|
||||
|
||||
/// Skip work-item status enrichment via GraphQL (overrides config)
|
||||
#[arg(long = "no-status")]
|
||||
pub no_status: bool,
|
||||
|
||||
/// Preview what would be synced without making changes
|
||||
#[arg(long, overrides_with = "no_dry_run")]
|
||||
pub dry_run: bool,
|
||||
|
||||
#[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")]
|
||||
pub no_dry_run: bool,
|
||||
|
||||
/// Show detailed timing breakdown for sync stages
|
||||
#[arg(short = 't', long = "timings")]
|
||||
pub timings: bool,
|
||||
|
||||
/// Acquire file lock before syncing (skip if another sync is running)
|
||||
#[arg(long)]
|
||||
pub lock: bool,
|
||||
|
||||
/// Surgically sync specific issues by IID (repeatable, must be positive)
|
||||
#[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)]
|
||||
pub issue: Vec<u64>,
|
||||
|
||||
/// Surgically sync specific merge requests by IID (repeatable, must be positive)
|
||||
#[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)]
|
||||
pub mr: Vec<u64>,
|
||||
|
||||
/// Scope to a single project (required when --issue or --mr is used)
|
||||
#[arg(short = 'p', long)]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Validate remote entities exist without DB writes (preflight only)
|
||||
#[arg(long)]
|
||||
pub preflight_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore embed # Embed new/changed documents
|
||||
lore embed --full # Re-embed all documents from scratch
|
||||
lore embed --retry-failed # Retry previously failed embeddings")]
|
||||
pub struct EmbedArgs {
|
||||
/// Re-embed all documents (clears existing embeddings first)
|
||||
#[arg(long, overrides_with = "no_full")]
|
||||
@@ -684,11 +925,15 @@ pub struct EmbedArgs {
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore timeline 'deployment' # Events related to deployments
|
||||
lore timeline 'deployment' # Search-based seeding
|
||||
lore timeline issue:42 # Direct: issue #42 and related entities
|
||||
lore timeline i:42 # Shorthand for issue:42
|
||||
lore timeline mr:99 # Direct: MR !99 and related entities
|
||||
lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time
|
||||
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
|
||||
lore timeline 'migration' --depth 2 # Deep cross-reference expansion
|
||||
lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")]
|
||||
pub struct TimelineArgs {
|
||||
/// Search query (keywords to find in issues, MRs, and discussions)
|
||||
/// Search text or entity reference (issue:N, i:N, mr:N, m:N)
|
||||
pub query: String,
|
||||
|
||||
/// Scope to a specific project (fuzzy match)
|
||||
@@ -703,9 +948,9 @@ pub struct TimelineArgs {
|
||||
#[arg(long, default_value = "1", help_heading = "Expansion")]
|
||||
pub depth: u32,
|
||||
|
||||
/// Also follow 'mentioned' edges during expansion (high fan-out)
|
||||
#[arg(long = "expand-mentions", help_heading = "Expansion")]
|
||||
pub expand_mentions: bool,
|
||||
/// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related')
|
||||
#[arg(long = "no-mentions", help_heading = "Expansion")]
|
||||
pub no_mentions: bool,
|
||||
|
||||
/// Maximum number of events to display
|
||||
#[arg(
|
||||
@@ -780,29 +1025,184 @@ pub struct WhoArgs {
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Maximum results per section (1..=500, bounded for output safety)
|
||||
/// Maximum results per section (1..=500); omit for unlimited
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long = "limit",
|
||||
default_value = "20",
|
||||
value_parser = clap::value_parser!(u16).range(1..=500),
|
||||
help_heading = "Output"
|
||||
)]
|
||||
pub limit: u16,
|
||||
pub limit: Option<u16>,
|
||||
|
||||
/// Select output fields (comma-separated, or 'minimal' preset; varies by mode)
|
||||
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||
pub fields: Option<Vec<String>>,
|
||||
|
||||
/// Show per-MR detail breakdown (expert mode only)
|
||||
#[arg(long, help_heading = "Output", overrides_with = "no_detail")]
|
||||
#[arg(
|
||||
long,
|
||||
help_heading = "Output",
|
||||
overrides_with = "no_detail",
|
||||
conflicts_with = "explain_score"
|
||||
)]
|
||||
pub detail: bool,
|
||||
|
||||
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
||||
pub no_detail: bool,
|
||||
|
||||
/// Score as if "now" is this date (ISO 8601 or duration like 30d). Expert mode only.
|
||||
#[arg(long = "as-of", help_heading = "Scoring")]
|
||||
pub as_of: Option<String>,
|
||||
|
||||
/// Show per-component score breakdown in output. Expert mode only.
|
||||
#[arg(long = "explain-score", help_heading = "Scoring")]
|
||||
pub explain_score: bool,
|
||||
|
||||
/// Include bot users in results (normally excluded via scoring.excluded_usernames).
|
||||
#[arg(long = "include-bots", help_heading = "Scoring")]
|
||||
pub include_bots: bool,
|
||||
|
||||
/// Include discussions on closed issues and merged/closed MRs
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub include_closed: bool,
|
||||
|
||||
/// Remove the default time window (query all history). Conflicts with --since.
|
||||
#[arg(
|
||||
long = "all-history",
|
||||
help_heading = "Filters",
|
||||
conflicts_with = "since"
|
||||
)]
|
||||
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>>,
|
||||
|
||||
/// Reset the since-last-check cursor (next run shows no new events)
|
||||
#[arg(long, help_heading = "Output")]
|
||||
pub reset_cursor: bool,
|
||||
}
|
||||
|
||||
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
|
||||
lore file-history src/auth/ -p group/repo # Scoped to project
|
||||
lore file-history src/foo.rs --discussions # Include DiffNote snippets
|
||||
lore file-history src/bar.rs --no-follow-renames # Skip rename chain")]
|
||||
pub struct FileHistoryArgs {
|
||||
/// File path to trace history for
|
||||
pub path: String,
|
||||
|
||||
/// Scope to a specific project (fuzzy match)
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Include discussion snippets from DiffNotes on this file
|
||||
#[arg(long, help_heading = "Output")]
|
||||
pub discussions: bool,
|
||||
|
||||
/// Disable rename chain resolution
|
||||
#[arg(long = "no-follow-renames", help_heading = "Filters")]
|
||||
pub no_follow_renames: bool,
|
||||
|
||||
/// Only show merged MRs
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub merged: bool,
|
||||
|
||||
/// Maximum results
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long = "limit",
|
||||
default_value = "50",
|
||||
help_heading = "Output"
|
||||
)]
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore trace src/main.rs # Why was this file changed?
|
||||
lore trace src/auth/ -p group/repo # Scoped to project
|
||||
lore trace src/foo.rs --discussions # Include DiffNote context
|
||||
lore trace src/bar.rs:42 # Line hint (Tier 2 warning)")]
|
||||
pub struct TraceArgs {
|
||||
/// File path to trace (supports :line suffix for future Tier 2)
|
||||
pub path: String,
|
||||
|
||||
/// Scope to a specific project (fuzzy match)
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Include DiffNote discussion snippets
|
||||
#[arg(long, help_heading = "Output")]
|
||||
pub discussions: bool,
|
||||
|
||||
/// Disable rename chain resolution
|
||||
#[arg(long = "no-follow-renames", help_heading = "Filters")]
|
||||
pub no_follow_renames: bool,
|
||||
|
||||
/// Maximum trace chains to display
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long = "limit",
|
||||
default_value = "20",
|
||||
help_heading = "Output"
|
||||
)]
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore count issues # Total issues in local database
|
||||
lore count notes --for mr # Notes on merge requests only
|
||||
lore count discussions --for issue # Discussions on issues only")]
|
||||
pub struct CountArgs {
|
||||
/// Entity type to count (issues, mrs, discussions, notes, events)
|
||||
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
|
||||
@@ -812,3 +1212,48 @@ pub struct CountArgs {
|
||||
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
|
||||
pub for_entity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct CronArgs {
|
||||
#[command(subcommand)]
|
||||
pub action: CronAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum CronAction {
|
||||
/// Install cron job for automatic syncing
|
||||
Install {
|
||||
/// Sync interval in minutes (default: 8)
|
||||
#[arg(long, default_value = "8")]
|
||||
interval: u32,
|
||||
},
|
||||
|
||||
/// Remove cron job
|
||||
Uninstall,
|
||||
|
||||
/// Show current cron configuration
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct TokenArgs {
|
||||
#[command(subcommand)]
|
||||
pub action: TokenAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum TokenAction {
|
||||
/// Store a GitLab token in the config file
|
||||
Set {
|
||||
/// Token value (reads from stdin if omitted in non-interactive mode)
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
},
|
||||
|
||||
/// Show the current token (masked by default)
|
||||
Show {
|
||||
/// Show the full unmasked token
|
||||
#[arg(long)]
|
||||
unmask: bool,
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user