Compare commits
17 Commits
8bd68e02bd
...
robot-meta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60075cd400 | ||
|
|
ddab186315 | ||
|
|
d6d1686f8e | ||
|
|
5c44ee91fb | ||
|
|
6aff96d32f | ||
|
|
06889ec85a | ||
|
|
08bda08934 | ||
|
|
32134ea933 | ||
|
|
16cc58b17f | ||
|
|
a10d870863 | ||
|
|
59088af2ab | ||
|
|
ace9c8bf17 | ||
|
|
cab8c540da | ||
|
|
d94bcbfbe7 | ||
|
|
62fbd7275e | ||
|
|
06852e90a6 | ||
|
|
4b0535f852 |
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-23xb
|
||||
bd-9lbr
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1324,7 +1324,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lore"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
dependencies = [
|
||||
"asupersync",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lore"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
edition = "2024"
|
||||
description = "Gitlore - Local GitLab data management with semantic search"
|
||||
authors = ["Taylor Eernisse"]
|
||||
|
||||
@@ -431,11 +431,12 @@ lore me --reset-cursor # Reset since-last-check cursor
|
||||
|
||||
The dashboard detects the current user from GitLab authentication and shows:
|
||||
- **Issues section**: Open issues assigned to you
|
||||
- **MRs section**: MRs you authored + MRs where you're a reviewer
|
||||
- **Activity section**: Recent events (state changes, comments, etc.) on your items
|
||||
- **MRs section**: Open MRs you authored + open MRs where you're a reviewer
|
||||
- **Activity section**: Recent events (state changes, comments, labels, milestones, assignments) on your items regardless of state — including closed issues and merged/closed MRs
|
||||
- **Mentions section**: Items where you're @mentioned but not assigned/authoring/reviewing
|
||||
- **Since last check**: Cursor-based inbox of actionable events from others since your last check, covering items in any state
|
||||
|
||||
The `--since` flag affects only the activity section. Other sections show current state regardless of time window.
|
||||
The `--since` flag affects only the activity section. The issues and MRs sections show open items only. The since-last-check inbox uses a persistent cursor (reset with `--reset-cursor`).
|
||||
|
||||
#### Field Selection (Robot Mode)
|
||||
|
||||
|
||||
388
command-restructure/CLI_AUDIT.md
Normal file
388
command-restructure/CLI_AUDIT.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Gitlore CLI Command Audit
|
||||
|
||||
## 1. Full Command Inventory
|
||||
|
||||
**29 visible + 4 hidden + 2 stub = 35 total command surface**
|
||||
|
||||
| # | Command | Aliases | Args | Flags | Purpose |
|
||||
|---|---------|---------|------|-------|---------|
|
||||
| 1 | `issues` | `issue` | `[IID]` | 15 | List/show issues |
|
||||
| 2 | `mrs` | `mr`, `merge-requests` | `[IID]` | 16 | List/show MRs |
|
||||
| 3 | `notes` | `note` | — | 16 | List notes |
|
||||
| 4 | `search` | `find`, `query` | `<QUERY>` | 13 | Hybrid FTS+vector search |
|
||||
| 5 | `timeline` | — | `<QUERY>` | 11 | Chronological event reconstruction |
|
||||
| 6 | `who` | — | `[TARGET]` | 16 | People intelligence (5 modes) |
|
||||
| 7 | `me` | — | — | 10 | Personal dashboard |
|
||||
| 8 | `file-history` | — | `<PATH>` | 6 | MRs that touched a file |
|
||||
| 9 | `trace` | — | `<PATH>` | 5 | file->MR->issue->discussion chain |
|
||||
| 10 | `drift` | — | `<TYPE> <IID>` | 3 | Discussion divergence detection |
|
||||
| 11 | `related` | — | `<QUERY_OR_TYPE> [IID]` | 3 | Semantic similarity |
|
||||
| 12 | `count` | — | `<ENTITY>` | 2 | Count entities |
|
||||
| 13 | `sync` | — | — | 14 | Full pipeline: ingest+docs+embed |
|
||||
| 14 | `ingest` | — | `[ENTITY]` | 5 | Fetch from GitLab API |
|
||||
| 15 | `generate-docs` | — | — | 2 | Build searchable documents |
|
||||
| 16 | `embed` | — | — | 2 | Generate vector embeddings |
|
||||
| 17 | `status` | `st` | — | 0 | Last sync times per project |
|
||||
| 18 | `health` | — | — | 0 | Quick pre-flight (exit code only) |
|
||||
| 19 | `doctor` | — | — | 0 | Full environment diagnostic |
|
||||
| 20 | `stats` | `stat` | — | 3 | Document/index statistics |
|
||||
| 21 | `init` | — | — | 6 | Setup config + database |
|
||||
| 22 | `auth` | — | — | 0 | Verify GitLab token |
|
||||
| 23 | `token` | — | subcommand | 1-2 | Token CRUD (set/show) |
|
||||
| 24 | `cron` | — | subcommand | 0-1 | Auto-sync scheduling |
|
||||
| 25 | `migrate` | — | — | 0 | Apply DB migrations |
|
||||
| 26 | `robot-docs` | — | — | 1 | Agent self-discovery manifest |
|
||||
| 27 | `completions` | — | `<SHELL>` | 0 | Shell completions |
|
||||
| 28 | `version` | — | — | 0 | Version info |
|
||||
| 29 | *help* | — | — | — | (clap built-in) |
|
||||
| | **Hidden/deprecated:** | | | | |
|
||||
| 30 | `list` | — | `<ENTITY>` | 14 | deprecated, use issues/mrs |
|
||||
| 31 | `auth-test` | — | — | 0 | deprecated, use auth |
|
||||
| 32 | `sync-status` | — | — | 0 | deprecated, use status |
|
||||
| 33 | `backup` | — | — | 0 | Stub (not implemented) |
|
||||
| 34 | `reset` | — | — | 1 | Stub (not implemented) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Semantic Overlap Analysis
|
||||
|
||||
### Cluster A: "Is the system working?" (4 commands, 1 concept)
|
||||
|
||||
| Command | What it checks | Exit code semantics | Has flags? |
|
||||
|---------|---------------|---------------------|------------|
|
||||
| `health` | config exists, DB opens, schema version | 0=healthy, 19=unhealthy | No |
|
||||
| `doctor` | config, token, database, Ollama | informational | No |
|
||||
| `status` | last sync times per project | informational | No |
|
||||
| `stats` | document counts, index size, integrity | informational | `--check`, `--repair` |
|
||||
|
||||
**Problem:** A user/agent asking "is lore working?" must choose among four commands. `health` is a strict subset of `doctor`. `status` and `stats` are near-homonyms that answer different questions -- sync recency vs. index health. `count` (Cluster E) also overlaps with what `stats` reports.
|
||||
|
||||
**Cognitive cost:** High. The CLI literature (Clig.dev, Heroku CLI design guide, 12-factor CLI) consistently warns against >2 "status" commands. Users build a mental model of "the status command" -- when there are four, they pick wrong or give up.
|
||||
|
||||
**Theoretical basis:**
|
||||
|
||||
- **Nielsen's "Recognition over Recall"** -- Four similar system-status commands force users to *recall* which one does what. One command with progressive disclosure (flags for depth) lets them *recognize* the option they need. This is doubly important for LLM agents, which perform better with fewer top-level choices and compositional flags.
|
||||
|
||||
- **Fitts's Law for CLIs** -- Command discovery cost is proportional to list length. Each additional top-level command adds scanning time for humans and token cost for robots.
|
||||
|
||||
### Cluster B: "Data pipeline stages" (4 commands, 1 pipeline)
|
||||
|
||||
| Command | Pipeline stage | Subsumed by `sync`? |
|
||||
|---------|---------------|---------------------|
|
||||
| `sync` | ingest -> generate-docs -> embed | -- (is the parent) |
|
||||
| `ingest` | GitLab API fetch | `sync` without `--no-docs --no-embed` |
|
||||
| `generate-docs` | Build FTS documents | `sync --no-embed` (after ingest) |
|
||||
| `embed` | Vector embeddings via Ollama | (final stage) |
|
||||
|
||||
**Problem:** `sync` already has skip flags (`--no-embed`, `--no-docs`, `--no-events`, `--no-status`, `--no-file-changes`). The individual stage commands duplicate this with less control -- `ingest` has `--full`, `--force`, `--dry-run`, but `sync` also has all three.
|
||||
|
||||
The standalone commands exist for granular debugging, but in practice they're reached for <5% of the time. They inflate the help screen while `sync` handles 95% of use cases.
|
||||
|
||||
### Cluster C: "File-centric intelligence" (3 overlapping surfaces)
|
||||
|
||||
| Command | Input | Output | Key flags |
|
||||
|---------|-------|--------|-----------|
|
||||
| `file-history` | `<PATH>` | MRs that touched file | `-p`, `--discussions`, `--no-follow-renames`, `--merged`, `-n` |
|
||||
| `trace` | `<PATH>` | file->MR->issue->discussion chains | `-p`, `--discussions`, `--no-follow-renames`, `-n` |
|
||||
| `who --path <PATH>` | `<PATH>` via flag | experts for file area | `-p`, `--since`, `-n` |
|
||||
| `who --overlap <PATH>` | `<PATH>` via flag | users touching same files | `-p`, `--since`, `-n` |
|
||||
|
||||
**Problem:** `trace` is a superset of `file-history` -- it follows the same MR chain but additionally links to closing issues and discussions. They share 4 of 5 filter flags. A user who wants "what happened to this file?" has to choose between two commands that sound nearly identical.
|
||||
|
||||
### Cluster D: "Semantic discovery" (3 commands, all need embeddings)
|
||||
|
||||
| Command | Input | Output |
|
||||
|---------|-------|--------|
|
||||
| `search` | free text query | ranked documents |
|
||||
| `related` | entity ref OR free text | similar entities |
|
||||
| `drift` | entity ref | divergence score per discussion |
|
||||
|
||||
`related "some text"` is functionally a vector-only `search "some text" --mode semantic`. The difference is that `related` can also seed from an entity (issues 42), while `search` only accepts text.
|
||||
|
||||
`drift` is specialized enough to stand alone, but it's only used on issues and has a single non-project flag (`--threshold`).
|
||||
|
||||
### Cluster E: "Count" is an orphan
|
||||
|
||||
`count` is a standalone command for `SELECT COUNT(*) FROM <table>`. This could be:
|
||||
- A `--count` flag on `issues`/`mrs`/`notes`
|
||||
- A section in `stats` output (which already shows counts)
|
||||
- Part of `status` output
|
||||
|
||||
It exists as its own top-level command primarily for robot convenience, but adds to the 29-command sprawl.
|
||||
|
||||
---
|
||||
|
||||
## 3. Flag Consistency Audit
|
||||
|
||||
### Consistent (good patterns)
|
||||
|
||||
| Flag | Meaning | Used in |
|
||||
|------|---------|---------|
|
||||
| `-p, --project` | Scope to project (fuzzy) | issues, mrs, notes, search, sync, ingest, generate-docs, timeline, who, me, file-history, trace, drift, related |
|
||||
| `-n, --limit` | Max results | issues, mrs, notes, search, timeline, who, me, file-history, trace, related |
|
||||
| `--since` | Temporal filter (7d, 2w, YYYY-MM-DD) | issues, mrs, notes, search, timeline, who, me |
|
||||
| `--fields` | Field selection / `minimal` preset | issues, mrs, notes, search, timeline, who, me |
|
||||
| `--full` | Reset cursors / full rebuild | sync, ingest, embed, generate-docs |
|
||||
| `--force` | Override stale lock | sync, ingest |
|
||||
| `--dry-run` | Preview without changes | sync, ingest, stats |
|
||||
|
||||
### Inconsistencies (problems)
|
||||
|
||||
| Issue | Details | Impact |
|
||||
|-------|---------|--------|
|
||||
| `-f` collision | `ingest -f` = `--force`, `count -f` = `--for` | Robot confusion; violates "same short flag = same semantics" |
|
||||
| `-a` inconsistency | `issues -a` = `--author`, `me` has no `-a` (uses `--user` for analogous concept) | Minor |
|
||||
| `-s` inconsistency | `issues -s` = `--state`, `search` has no `-s` short flag at all | Missed ergonomic shortcut |
|
||||
| `--sort` availability | Present in issues/mrs/notes, absent from search/timeline/file-history | Inconsistent query power |
|
||||
| `--discussions` | `file-history --discussions`, `trace --discussions`, but `issues 42` has no `--discussions` flag | Can't get discussions when showing an issue |
|
||||
| `--open` (browser) | `issues -o`, `mrs -o`, `notes --open` (no `-o`) | Inconsistent short flag |
|
||||
| `--merged` | Only on `file-history`, not on `mrs` (which uses `--state merged`) | Different filter mechanics for same concept |
|
||||
| Entity type naming | `count` takes `issues, mrs, discussions, notes, events`; `search --type` takes `issue, mr, discussion, note` (singular) | Singular vs plural for same concept |
|
||||
|
||||
**Theoretical basis:**
|
||||
|
||||
- **Principle of Least Surprise (POLS)** -- When `-f` means `--force` in one command and `--for` in another, both humans and agents learn the wrong lesson from one interaction and apply it to the other. CLI design guides (GNU standards, POSIX conventions, clig.dev) are unanimous: short flags should have consistent semantics across all subcommands.
|
||||
|
||||
- **Singular/plural inconsistency** (`issues` vs `issue` as entity type values) is particularly harmful for LLM agents, which use pattern matching on prior successful invocations. If `lore count issues` works, the agent will try `lore search --type issues` -- and get a parse error.
|
||||
|
||||
---
|
||||
|
||||
## 4. Robot Ergonomics Assessment
|
||||
|
||||
### Strengths (well above average for a CLI)
|
||||
|
||||
| Feature | Rating | Notes |
|
||||
|---------|--------|-------|
|
||||
| Structured output | Excellent | Consistent `{ok, data, meta}` envelope |
|
||||
| Auto-detection | Excellent | Non-TTY -> robot mode, `LORE_ROBOT` env var |
|
||||
| Error output | Excellent | Structured JSON to stderr with `actions` array for recovery |
|
||||
| Exit codes | Excellent | 20 distinct, well-documented codes |
|
||||
| Self-discovery | Excellent | `robot-docs` manifest, `--brief` for token savings |
|
||||
| Typo tolerance | Excellent | Autocorrect with confidence scores + structured warnings |
|
||||
| Field selection | Good | `--fields minimal` saves ~60% tokens |
|
||||
| No-args behavior | Good | Robot mode auto-outputs robot-docs |
|
||||
|
||||
### Weaknesses
|
||||
|
||||
| Issue | Severity | Recommendation |
|
||||
|-------|----------|----------------|
|
||||
| 29 commands in robot-docs manifest | High | Agents spend tokens evaluating which command to use. Grouping would reduce decision space. |
|
||||
| `status`/`stats`/`stat` near-homonyms | High | LLMs are particularly susceptible to surface-level lexical confusion. `stat` is an alias for `stats` while `status` is a different command -- this guarantees agent errors. |
|
||||
| Singular vs plural entity types | Medium | `count issues` works but `search --type issues` fails. Agents learn from one and apply to the other. |
|
||||
| Overlapping file commands | Medium | Agent must decide between `trace`, `file-history`, and `who --path`. The decision tree isn't obvious from names alone. |
|
||||
| `count` as separate command | Low | Could be a flag; standalone command inflates the decision space |
|
||||
|
||||
---
|
||||
|
||||
## 5. Human Ergonomics Assessment
|
||||
|
||||
### Strengths
|
||||
|
||||
| Feature | Rating | Notes |
|
||||
|---------|--------|-------|
|
||||
| Help text quality | Excellent | Every command has examples, help headings organize flags |
|
||||
| Short flags | Good | `-p`, `-n`, `-s`, `-a`, `-J` cover 80% of common use |
|
||||
| Alias coverage | Good | `issue`/`issues`, `mr`/`mrs`, `st`/`status`, `find`/`search` |
|
||||
| Subcommand inference | Good | `lore issu` -> `issues` via clap infer |
|
||||
| Color/icon system | Good | Auto, with overrides |
|
||||
|
||||
### Weaknesses
|
||||
|
||||
| Issue | Severity | Recommendation |
|
||||
|-------|----------|----------------|
|
||||
| 29 commands in flat help | High | Doesn't fit one terminal screen. No grouping -> overwhelming |
|
||||
| `status` vs `stats` naming | High | Humans will type wrong one repeatedly |
|
||||
| `health` vs `doctor` distinction | Medium | "Which one do I run?" -- unclear from names |
|
||||
| `who` 5-mode overload | Medium | Help text is long; mode exclusions are complex |
|
||||
| Pipeline stages as top-level | Low | `ingest`/`generate-docs`/`embed` rarely used directly but clutter help |
|
||||
| `generate-docs` is 14 chars | Low | Longest command name; `gen-docs` or `gendocs` would help |
|
||||
|
||||
---
|
||||
|
||||
## 6. Proposals (Ranked by Impact x Feasibility)
|
||||
|
||||
### P1: Help Grouping (HIGH impact, LOW effort)
|
||||
|
||||
**Problem:** 29 flat commands -> information overload.
|
||||
|
||||
**Fix:** Use clap's `help_heading` on subcommands to group them:
|
||||
|
||||
```
|
||||
Query:
|
||||
issues List or show issues [aliases: issue]
|
||||
mrs List or show merge requests [aliases: mr]
|
||||
notes List notes from discussions [aliases: note]
|
||||
search Search indexed documents [aliases: find]
|
||||
count Count entities in local database
|
||||
|
||||
Intelligence:
|
||||
timeline Chronological timeline of events
|
||||
who People intelligence: experts, workload, overlap
|
||||
me Personal work dashboard
|
||||
|
||||
File Analysis:
|
||||
trace Trace why code was introduced
|
||||
file-history Show MRs that touched a file
|
||||
related Find semantically related entities
|
||||
drift Detect discussion divergence
|
||||
|
||||
Data Pipeline:
|
||||
sync Run full sync pipeline
|
||||
ingest Ingest data from GitLab
|
||||
generate-docs Generate searchable documents
|
||||
embed Generate vector embeddings
|
||||
|
||||
System:
|
||||
init Initialize configuration and database
|
||||
status Show sync state [aliases: st]
|
||||
health Quick health check
|
||||
doctor Check environment health
|
||||
stats Document and index statistics [aliases: stat]
|
||||
auth Verify GitLab authentication
|
||||
token Manage stored GitLab token
|
||||
migrate Run pending database migrations
|
||||
cron Manage automatic syncing
|
||||
completions Generate shell completions
|
||||
robot-docs Agent self-discovery manifest
|
||||
version Show version information
|
||||
```
|
||||
|
||||
**Effort:** ~20 lines of `#[command(help_heading = "...")]` annotations. No behavior changes.
|
||||
|
||||
### P2: Resolve `status`/`stats` Confusion (HIGH impact, LOW effort)
|
||||
|
||||
**Option A (recommended):** Rename `stats` -> `index`.
|
||||
- `lore status` = when did I last sync? (pipeline state)
|
||||
- `lore index` = how big is my index? (data inventory)
|
||||
- The alias `stat` goes away (it was causing confusion anyway)
|
||||
|
||||
**Option B:** Rename `status` -> `sync-state` and `stats` -> `db-stats`. More descriptive but longer.
|
||||
|
||||
**Option C:** Merge both under `check` (see P4).
|
||||
|
||||
### P3: Fix Singular/Plural Entity Type Inconsistency (MEDIUM impact, TRIVIAL effort)
|
||||
|
||||
Accept both singular and plural forms everywhere:
|
||||
- `count` already takes `issues` (plural) -- also accept `issue`
|
||||
- `search --type` already takes `issue` (singular) -- also accept `issues`
|
||||
- `drift` takes `issues` -- also accept `issue`
|
||||
|
||||
This is a ~10 line change in the value parsers and eliminates an entire class of agent errors.
|
||||
|
||||
### P4: Merge `health` + `doctor` (MEDIUM impact, LOW effort)
|
||||
|
||||
`health` is a fast subset of `doctor`. Merge:
|
||||
- `lore doctor` = full diagnostic (current behavior)
|
||||
- `lore doctor --quick` = fast pre-flight, exit-code-only (current `health`)
|
||||
- Drop `health` as a separate command, add a hidden alias for backward compat
|
||||
|
||||
### P5: Fix `-f` Short Flag Collision (MEDIUM impact, TRIVIAL effort)
|
||||
|
||||
Change `count`'s `-f, --for` to just `--for` (no short flag). `-f` should mean `--force` project-wide, or nowhere.
|
||||
|
||||
### P6: Consolidate `trace` + `file-history` (MEDIUM impact, MEDIUM effort)
|
||||
|
||||
`trace` already does everything `file-history` does plus more. Options:
|
||||
|
||||
**Option A:** Make `file-history` an alias for `trace --flat` (shows MR list without issue/discussion linking).
|
||||
|
||||
**Option B:** Add `--mrs-only` to `trace` that produces `file-history` output. Deprecate `file-history` with a hidden alias.
|
||||
|
||||
Either way, one fewer top-level command and no lost functionality.
|
||||
|
||||
### P7: Hide Pipeline Sub-stages (LOW impact, TRIVIAL effort)
|
||||
|
||||
Move `ingest`, `generate-docs`, `embed` to `#[command(hide = true)]`. They remain usable but don't clutter `--help`. Direct users to `sync` with stage-skip flags.
|
||||
|
||||
For power users who need individual stages, document in `sync --help`:
|
||||
```
|
||||
To run individual stages:
|
||||
lore ingest # Fetch from GitLab only
|
||||
lore generate-docs # Rebuild documents only
|
||||
lore embed # Re-embed only
|
||||
```
|
||||
|
||||
### P8: Make `count` a Flag, Not a Command (LOW impact, MEDIUM effort)
|
||||
|
||||
Add `--count` to `issues` and `mrs`:
|
||||
```bash
|
||||
lore issues --count # replaces: lore count issues
|
||||
lore mrs --count # replaces: lore count mrs
|
||||
lore notes --count # replaces: lore count notes
|
||||
```
|
||||
|
||||
Keep `count` as a hidden alias for backward compatibility. Removes one top-level command.
|
||||
|
||||
### P9: Consistent `--open` Short Flag (LOW impact, TRIVIAL effort)
|
||||
|
||||
`notes --open` lacks the `-o` shorthand that `issues` and `mrs` have. Add it.
|
||||
|
||||
### P10: Add `--sort` to `search` (LOW impact, LOW effort)
|
||||
|
||||
`search` returns ranked results but offers no `--sort` override. Adding `--sort=score,created,updated` would bring it in line with `issues`/`mrs`/`notes`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary: Proposed Command Tree (After All Changes)
|
||||
|
||||
If all proposals were adopted, the visible top-level shrinks from **29 -> 21**:
|
||||
|
||||
| Before (29) | After (21) | Change |
|
||||
|-------------|------------|--------|
|
||||
| `issues` | `issues` | -- |
|
||||
| `mrs` | `mrs` | -- |
|
||||
| `notes` | `notes` | -- |
|
||||
| `search` | `search` | -- |
|
||||
| `timeline` | `timeline` | -- |
|
||||
| `who` | `who` | -- |
|
||||
| `me` | `me` | -- |
|
||||
| `file-history` | *(hidden, alias for `trace --flat`)* | **merged into trace** |
|
||||
| `trace` | `trace` | absorbs file-history |
|
||||
| `drift` | `drift` | -- |
|
||||
| `related` | `related` | -- |
|
||||
| `count` | *(hidden, `issues --count` replaces)* | **absorbed** |
|
||||
| `sync` | `sync` | -- |
|
||||
| `ingest` | *(hidden)* | **hidden** |
|
||||
| `generate-docs` | *(hidden)* | **hidden** |
|
||||
| `embed` | *(hidden)* | **hidden** |
|
||||
| `status` | `status` | -- |
|
||||
| `health` | *(merged into doctor)* | **merged** |
|
||||
| `doctor` | `doctor` | absorbs health |
|
||||
| `stats` | `index` | **renamed** |
|
||||
| `init` | `init` | -- |
|
||||
| `auth` | `auth` | -- |
|
||||
| `token` | `token` | -- |
|
||||
| `migrate` | `migrate` | -- |
|
||||
| `cron` | `cron` | -- |
|
||||
| `robot-docs` | `robot-docs` | -- |
|
||||
| `completions` | `completions` | -- |
|
||||
| `version` | `version` | -- |
|
||||
|
||||
**Net reduction:** 29 -> 21 visible (-28%). The hidden commands remain fully functional and documented in `robot-docs` for agents that already use them.
|
||||
|
||||
**Theoretical basis:**
|
||||
|
||||
- **Miller's Law** -- Humans can hold 7+/-2 items in working memory. 29 commands far exceeds this. Even with help grouping (P1), the sheer count creates decision fatigue. The literature on CLI design (Heroku's "12-Factor CLI", clig.dev's "Command Line Interface Guidelines") recommends 10-15 top-level commands maximum, with grouping or nesting for anything beyond.
|
||||
|
||||
- **For LLM agents specifically:** Research on tool-use with large tool sets (Schick et al. 2023, Qin et al. 2023) shows that agent accuracy degrades as the tool count increases, roughly following an inverse log curve. Reducing from 29 to 21 commands in the robot-docs manifest would measurably improve agent command selection accuracy.
|
||||
|
||||
- **Backward compatibility is free:** Since AGENTS.md says "we don't care about backward compatibility," hidden aliases cost nothing and prevent breakage for agents with cached robot-docs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Priority Matrix
|
||||
|
||||
| Proposal | Impact | Effort | Risk | Recommended Order |
|
||||
|----------|--------|--------|------|-------------------|
|
||||
| P1: Help grouping | High | Trivial | None | **Do first** |
|
||||
| P3: Singular/plural fix | Medium | Trivial | None | **Do first** |
|
||||
| P5: Fix `-f` collision | Medium | Trivial | None | **Do first** |
|
||||
| P9: `notes -o` shorthand | Low | Trivial | None | **Do first** |
|
||||
| P2: Rename `stats`->`index` | High | Low | Alias needed | **Do second** |
|
||||
| P4: Merge health->doctor | Medium | Low | Alias needed | **Do second** |
|
||||
| P7: Hide pipeline stages | Low | Trivial | Needs docs update | **Do second** |
|
||||
| P6: Merge file-history->trace | Medium | Medium | Flag design | **Plan carefully** |
|
||||
| P8: count -> --count flag | Low | Medium | Compat shim | **Plan carefully** |
|
||||
| P10: `--sort` on search | Low | Low | None | **When convenient** |
|
||||
|
||||
The "do first" tier is 4 changes that could ship in a single commit with zero risk and immediate ergonomic improvement for both humans and agents.
|
||||
966
command-restructure/IMPLEMENTATION_PLAN.md
Normal file
966
command-restructure/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,966 @@
|
||||
# Command Restructure: Implementation Plan
|
||||
|
||||
**Reference:** `command-restructure/CLI_AUDIT.md`
|
||||
**Scope:** 10 proposals, 3 implementation phases, estimated ~15 files touched
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Zero-Risk Quick Wins (1 commit)
|
||||
|
||||
These four changes are purely additive -- no behavior changes, no renames, no removed commands.
|
||||
|
||||
### P1: Help Grouping
|
||||
|
||||
**Goal:** Group the 29 visible commands into 5 semantic clusters in `--help` output.
|
||||
|
||||
**File:** `src/cli/mod.rs` (lines 117-399, the `Commands` enum)
|
||||
|
||||
**Changes:** Add `#[command(help_heading = "...")]` to each variant:
|
||||
|
||||
```rust
|
||||
#[derive(Subcommand)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Commands {
|
||||
// ── Query ──────────────────────────────────────────────
|
||||
/// List or show issues
|
||||
#[command(visible_alias = "issue", help_heading = "Query")]
|
||||
Issues(IssuesArgs),
|
||||
|
||||
/// List or show merge requests
|
||||
#[command(visible_alias = "mr", alias = "merge-requests", alias = "merge-request", help_heading = "Query")]
|
||||
Mrs(MrsArgs),
|
||||
|
||||
/// List notes from discussions
|
||||
#[command(visible_alias = "note", help_heading = "Query")]
|
||||
Notes(NotesArgs),
|
||||
|
||||
/// Search indexed documents
|
||||
#[command(visible_alias = "find", alias = "query", help_heading = "Query")]
|
||||
Search(SearchArgs),
|
||||
|
||||
/// Count entities in local database
|
||||
#[command(help_heading = "Query")]
|
||||
Count(CountArgs),
|
||||
|
||||
// ── Intelligence ───────────────────────────────────────
|
||||
/// Show a chronological timeline of events matching a query
|
||||
#[command(help_heading = "Intelligence")]
|
||||
Timeline(TimelineArgs),
|
||||
|
||||
/// People intelligence: experts, workload, active discussions, overlap
|
||||
#[command(help_heading = "Intelligence")]
|
||||
Who(WhoArgs),
|
||||
|
||||
/// Personal work dashboard: open issues, authored/reviewing MRs, activity
|
||||
#[command(help_heading = "Intelligence")]
|
||||
Me(MeArgs),
|
||||
|
||||
// ── File Analysis ──────────────────────────────────────
|
||||
/// Trace why code was introduced: file -> MR -> issue -> discussion
|
||||
#[command(help_heading = "File Analysis")]
|
||||
Trace(TraceArgs),
|
||||
|
||||
/// Show MRs that touched a file, with linked discussions
|
||||
#[command(name = "file-history", help_heading = "File Analysis")]
|
||||
FileHistory(FileHistoryArgs),
|
||||
|
||||
/// Find semantically related entities via vector search
|
||||
#[command(help_heading = "File Analysis", ...)]
|
||||
Related { ... },
|
||||
|
||||
/// Detect discussion divergence from original intent
|
||||
#[command(help_heading = "File Analysis", ...)]
|
||||
Drift { ... },
|
||||
|
||||
// ── Data Pipeline ──────────────────────────────────────
|
||||
/// Run full sync pipeline: ingest -> generate-docs -> embed
|
||||
#[command(help_heading = "Data Pipeline")]
|
||||
Sync(SyncArgs),
|
||||
|
||||
/// Ingest data from GitLab
|
||||
#[command(help_heading = "Data Pipeline")]
|
||||
Ingest(IngestArgs),
|
||||
|
||||
/// Generate searchable documents from ingested data
|
||||
#[command(name = "generate-docs", help_heading = "Data Pipeline")]
|
||||
GenerateDocs(GenerateDocsArgs),
|
||||
|
||||
/// Generate vector embeddings for documents via Ollama
|
||||
#[command(help_heading = "Data Pipeline")]
|
||||
Embed(EmbedArgs),
|
||||
|
||||
// ── System ─────────────────────────────────────────────
|
||||
// (init, status, health, doctor, stats, auth, token, migrate, cron,
|
||||
// completions, robot-docs, version -- all get help_heading = "System")
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore --help` shows grouped output
|
||||
- All existing commands still work identically
|
||||
- `lore robot-docs` output unchanged (robot-docs is hand-crafted, not derived from clap)
|
||||
|
||||
**Files touched:** `src/cli/mod.rs` only
|
||||
|
||||
---
|
||||
|
||||
### P3: Singular/Plural Entity Type Fix
|
||||
|
||||
**Goal:** Accept both `issue`/`issues`, `mr`/`mrs` everywhere entity types are value-parsed.
|
||||
|
||||
**File:** `src/cli/args.rs`
|
||||
|
||||
**Change 1 -- `CountArgs.entity` (line 819):**
|
||||
```rust
|
||||
// BEFORE:
|
||||
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
|
||||
pub entity: String,
|
||||
|
||||
// AFTER:
|
||||
#[arg(value_parser = ["issue", "issues", "mr", "mrs", "discussion", "discussions", "note", "notes", "event", "events"])]
|
||||
pub entity: String,
|
||||
```
|
||||
|
||||
**File:** `src/cli/args.rs`
|
||||
|
||||
**Change 2 -- `SearchArgs.source_type` (line 369):**
|
||||
```rust
|
||||
// BEFORE:
|
||||
#[arg(long = "type", value_parser = ["issue", "mr", "discussion", "note"], ...)]
|
||||
pub source_type: Option<String>,
|
||||
|
||||
// AFTER:
|
||||
#[arg(long = "type", value_parser = ["issue", "issues", "mr", "mrs", "discussion", "discussions", "note", "notes"], ...)]
|
||||
pub source_type: Option<String>,
|
||||
```
|
||||
|
||||
**File:** `src/cli/mod.rs`
|
||||
|
||||
**Change 3 -- `Drift.entity_type` (line 287):**
|
||||
```rust
|
||||
// BEFORE:
|
||||
#[arg(value_parser = ["issues"])]
|
||||
pub entity_type: String,
|
||||
|
||||
// AFTER:
|
||||
#[arg(value_parser = ["issue", "issues"])]
|
||||
pub entity_type: String,
|
||||
```
|
||||
|
||||
**Normalization layer:** In the handlers that consume these values, normalize to the canonical form (plural for entity names, singular for source_type) so downstream code doesn't need changes:
|
||||
|
||||
**File:** `src/app/handlers.rs`
|
||||
|
||||
In `handle_count` (~line 409): Normalize entity string before passing to `run_count`:
|
||||
```rust
|
||||
let entity = match args.entity.as_str() {
|
||||
"issue" => "issues",
|
||||
"mr" => "mrs",
|
||||
"discussion" => "discussions",
|
||||
"note" => "notes",
|
||||
"event" => "events",
|
||||
other => other,
|
||||
};
|
||||
```
|
||||
|
||||
In `handle_search` (search handler): Normalize source_type:
|
||||
```rust
|
||||
let source_type = args.source_type.as_deref().map(|t| match t {
|
||||
"issues" => "issue",
|
||||
"mrs" => "mr",
|
||||
"discussions" => "discussion",
|
||||
"notes" => "note",
|
||||
other => other,
|
||||
});
|
||||
```
|
||||
|
||||
In `handle_drift` (~line 225): Normalize entity_type:
|
||||
```rust
|
||||
let entity_type = if entity_type == "issue" { "issues" } else { &entity_type };
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore count issue` works (same as `lore count issues`)
|
||||
- `lore search --type issues 'foo'` works (same as `--type issue`)
|
||||
- `lore drift issue 42` works (same as `drift issues 42`)
|
||||
- All existing invocations unchanged
|
||||
|
||||
**Files touched:** `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`
|
||||
|
||||
---
|
||||
|
||||
### P5: Fix `-f` Short Flag Collision
|
||||
|
||||
**Goal:** Remove `-f` shorthand from `count --for` so `-f` consistently means `--force` across the CLI.
|
||||
|
||||
**File:** `src/cli/args.rs` (line 823)
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
|
||||
pub for_entity: Option<String>,
|
||||
|
||||
// AFTER:
|
||||
#[arg(long = "for", value_parser = ["issue", "mr"])]
|
||||
pub for_entity: Option<String>,
|
||||
```
|
||||
|
||||
**Also update the value_parser to accept both forms** (while we're here):
|
||||
```rust
|
||||
#[arg(long = "for", value_parser = ["issue", "issues", "mr", "mrs"])]
|
||||
pub for_entity: Option<String>,
|
||||
```
|
||||
|
||||
And normalize in `handle_count`:
|
||||
```rust
|
||||
let for_entity = args.for_entity.as_deref().map(|f| match f {
|
||||
"issues" => "issue",
|
||||
"mrs" => "mr",
|
||||
other => other,
|
||||
});
|
||||
```
|
||||
|
||||
**File:** `src/app/robot_docs.rs` (line 173) -- update the robot-docs entry:
|
||||
```rust
|
||||
// BEFORE:
|
||||
"flags": ["<entity: issues|mrs|discussions|notes|events>", "-f/--for <issue|mr>"],
|
||||
|
||||
// AFTER:
|
||||
"flags": ["<entity: issues|mrs|discussions|notes|events>", "--for <issue|mr>"],
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore count notes --for mr` still works
|
||||
- `lore count notes -f mr` now fails with a clear error (unknown flag `-f`)
|
||||
- `lore ingest -f` still works (means `--force`)
|
||||
|
||||
**Files touched:** `src/cli/args.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
---
|
||||
|
||||
### P9: Consistent `--open` Short Flag on `notes`
|
||||
|
||||
**Goal:** Add `-o` shorthand to `notes --open`, matching `issues` and `mrs`.
|
||||
|
||||
**File:** `src/cli/args.rs` (line 292)
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
#[arg(long, help_heading = "Actions")]
|
||||
pub open: bool,
|
||||
|
||||
// AFTER:
|
||||
#[arg(short = 'o', long, help_heading = "Actions", overrides_with = "no_open")]
|
||||
pub open: bool,
|
||||
|
||||
#[arg(long = "no-open", hide = true, overrides_with = "open")]
|
||||
pub no_open: bool,
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore notes -o` opens first result in browser
|
||||
- Matches behavior of `lore issues -o` and `lore mrs -o`
|
||||
|
||||
**Files touched:** `src/cli/args.rs`
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 Commit Summary
|
||||
|
||||
**Files modified:**
|
||||
1. `src/cli/mod.rs` -- help_heading on all Commands variants + drift value_parser
|
||||
2. `src/cli/args.rs` -- singular/plural value_parsers, remove `-f` from count, add `-o` to notes
|
||||
3. `src/app/handlers.rs` -- normalization of entity/source_type strings
|
||||
4. `src/app/robot_docs.rs` -- update count flags documentation
|
||||
|
||||
**Test plan:**
|
||||
```bash
|
||||
cargo check --all-targets
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
lore --help # Verify grouped output
|
||||
lore count issue # Verify singular accepted
|
||||
lore search --type issues 'x' # Verify plural accepted
|
||||
lore drift issue 42 # Verify singular accepted
|
||||
lore notes -o # Verify short flag works
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Renames and Merges (2-3 commits)
|
||||
|
||||
These changes rename commands and merge overlapping ones. Hidden aliases preserve backward compatibility.
|
||||
|
||||
### P2: Rename `stats` -> `index`
|
||||
|
||||
**Goal:** Eliminate `status`/`stats`/`stat` confusion. `stats` becomes `index`.
|
||||
|
||||
**File:** `src/cli/mod.rs`
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
/// Show document and index statistics
|
||||
#[command(visible_alias = "stat", help_heading = "System")]
|
||||
Stats(StatsArgs),
|
||||
|
||||
// AFTER:
|
||||
/// Show document and index statistics
|
||||
#[command(visible_alias = "idx", alias = "stats", alias = "stat", help_heading = "System")]
|
||||
Index(StatsArgs),
|
||||
```
|
||||
|
||||
Note: `alias = "stats"` and `alias = "stat"` are hidden aliases (not `visible_alias`) -- old invocations still work, but `--help` shows `index`.
|
||||
|
||||
**File:** `src/main.rs` (line 257)
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
|
||||
|
||||
// AFTER:
|
||||
Some(Commands::Index(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
|
||||
```
|
||||
|
||||
**File:** `src/app/robot_docs.rs` (line 181)
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
"stats": {
|
||||
"description": "Show document and index statistics",
|
||||
...
|
||||
|
||||
// AFTER:
|
||||
"index": {
|
||||
"description": "Show document and index statistics (formerly 'stats')",
|
||||
...
|
||||
```
|
||||
|
||||
Also update references in:
|
||||
- `robot_docs.rs` quick_start.lore_exclusive array (line 415): `"stats: Database statistics..."` -> `"index: Database statistics..."`
|
||||
- `robot_docs.rs` aliases.deprecated_commands: add `"stats": "index"`, `"stat": "index"`
|
||||
|
||||
**File:** `src/cli/autocorrect.rs`
|
||||
|
||||
Update `CANONICAL_SUBCOMMANDS` (line 366-area):
|
||||
```rust
|
||||
// Replace "stats" with "index" in the canonical list
|
||||
// Add ("stats", "index") and ("stat", "index") to SUBCOMMAND_ALIASES
|
||||
```
|
||||
|
||||
Update `COMMAND_FLAGS` (line 166-area):
|
||||
```rust
|
||||
// BEFORE:
|
||||
("stats", &["--check", ...]),
|
||||
|
||||
// AFTER:
|
||||
("index", &["--check", ...]),
|
||||
```
|
||||
|
||||
**File:** `src/cli/robot.rs` -- update `expand_fields_preset` if any preset key is `"stats"` (currently no stats preset, so no change needed).
|
||||
|
||||
**Verification:**
|
||||
- `lore index` works (shows document/index stats)
|
||||
- `lore stats` still works (hidden alias)
|
||||
- `lore stat` still works (hidden alias)
|
||||
- `lore index --check` works
|
||||
- `lore --help` shows `index` in System group, not `stats`
|
||||
- `lore robot-docs` shows `index` key in commands map
|
||||
|
||||
**Files touched:** `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
|
||||
|
||||
---
|
||||
|
||||
### P4: Merge `health` into `doctor`
|
||||
|
||||
**Goal:** One diagnostic command (`doctor`) with a `--quick` flag for the pre-flight check that `health` currently provides.
|
||||
|
||||
**File:** `src/cli/mod.rs`
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
/// Quick health check: config, database, schema version
|
||||
#[command(after_help = "...")]
|
||||
Health,
|
||||
|
||||
/// Check environment health
|
||||
#[command(after_help = "...")]
|
||||
Doctor,
|
||||
|
||||
// AFTER:
|
||||
// Remove Health variant entirely. Add hidden alias:
|
||||
/// Check environment health (--quick for fast pre-flight)
|
||||
#[command(
|
||||
after_help = "...",
|
||||
alias = "health", // hidden backward compat
|
||||
help_heading = "System"
|
||||
)]
|
||||
Doctor {
|
||||
/// Fast pre-flight check only (config, DB, schema). Exit 0 = healthy.
|
||||
#[arg(long)]
|
||||
quick: bool,
|
||||
},
|
||||
```
|
||||
|
||||
**File:** `src/main.rs`
|
||||
|
||||
```rust
|
||||
// BEFORE:
|
||||
Some(Commands::Doctor) => handle_doctor(cli.config.as_deref(), robot_mode).await,
|
||||
...
|
||||
Some(Commands::Health) => handle_health(cli.config.as_deref(), robot_mode).await,
|
||||
|
||||
// AFTER:
|
||||
Some(Commands::Doctor { quick }) => {
|
||||
if quick {
|
||||
handle_health(cli.config.as_deref(), robot_mode).await
|
||||
} else {
|
||||
handle_doctor(cli.config.as_deref(), robot_mode).await
|
||||
}
|
||||
}
|
||||
// Health variant removed from enum, so no separate match arm
|
||||
```
|
||||
|
||||
**File:** `src/app/robot_docs.rs`
|
||||
|
||||
Merge the `health` and `doctor` entries:
|
||||
```rust
|
||||
"doctor": {
|
||||
"description": "Environment health check. Use --quick for fast pre-flight (exit 0 = healthy, 19 = unhealthy).",
|
||||
"flags": ["--quick"],
|
||||
"example": "lore --robot doctor",
|
||||
"notes": {
|
||||
"quick_mode": "lore --robot doctor --quick — fast pre-flight check (formerly 'lore health'). Only checks config, DB, schema version. Returns exit 19 on failure.",
|
||||
"full_mode": "lore --robot doctor — full diagnostic: config, auth, database, Ollama"
|
||||
},
|
||||
"response_schema": {
|
||||
"full": { ... }, // current doctor schema
|
||||
"quick": { ... } // current health schema
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the standalone `health` entry from the commands map.
|
||||
|
||||
**File:** `src/cli/autocorrect.rs`
|
||||
|
||||
- Remove `"health"` from `CANONICAL_SUBCOMMANDS` (clap's `alias` handles it)
|
||||
- Or keep it -- since clap treats aliases as valid subcommands, the autocorrect system will still resolve typos like `"helth"` to `"health"` which clap then maps to `doctor`. Either way works.
|
||||
|
||||
**File:** `src/app/robot_docs.rs` -- update `workflows.pre_flight`:
|
||||
```rust
|
||||
"pre_flight": [
|
||||
"lore --robot doctor --quick"
|
||||
],
|
||||
```
|
||||
|
||||
Add to aliases.deprecated_commands:
|
||||
```rust
|
||||
"health": "doctor --quick"
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore doctor` runs full diagnostic (unchanged behavior)
|
||||
- `lore doctor --quick` runs fast pre-flight (exit 0/19)
|
||||
- `lore health` still works (hidden alias, runs `doctor --quick`)
|
||||
- `lore --help` shows only `doctor` in System group
|
||||
- `lore robot-docs` shows merged entry
|
||||
|
||||
**Files touched:** `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
|
||||
|
||||
**Important edge case:** `lore health` via the hidden alias will invoke `Doctor { quick: false }` unless we handle it specially. Two options:
|
||||
|
||||
**Option A (simpler):** Instead of making `health` an alias of `doctor`, keep both variants but hide `Health`:
|
||||
```rust
|
||||
#[command(hide = true, help_heading = "System")]
|
||||
Health,
|
||||
```
|
||||
Then in `main.rs`, `Commands::Health` maps to `handle_health()` as before. This is less clean but zero-risk.
|
||||
|
||||
**Option B (cleaner):** In the autocorrect layer, rewrite `health` -> `doctor --quick` before clap parsing:
|
||||
```rust
|
||||
// In SUBCOMMAND_ALIASES or a new pre-clap rewrite:
|
||||
("health", "doctor"), // plus inject "--quick" flag
|
||||
```
|
||||
This requires a small enhancement to autocorrect to support flag injection during alias resolution.
|
||||
|
||||
**Recommendation:** Use Option A for initial implementation. It's one line (`hide = true`) and achieves the goal of removing `health` from `--help` while preserving full backward compatibility. The `doctor --quick` flag is additive.
|
||||
|
||||
---
|
||||
|
||||
### P7: Hide Pipeline Sub-stages
|
||||
|
||||
**Goal:** Remove `ingest`, `generate-docs`, `embed` from `--help` while keeping them fully functional.
|
||||
|
||||
**File:** `src/cli/mod.rs`
|
||||
|
||||
```rust
|
||||
// Add hide = true to each:
|
||||
|
||||
/// Ingest data from GitLab
|
||||
#[command(hide = true)]
|
||||
Ingest(IngestArgs),
|
||||
|
||||
/// Generate searchable documents from ingested data
|
||||
#[command(name = "generate-docs", hide = true)]
|
||||
GenerateDocs(GenerateDocsArgs),
|
||||
|
||||
/// Generate vector embeddings for documents via Ollama
|
||||
#[command(hide = true)]
|
||||
Embed(EmbedArgs),
|
||||
```
|
||||
|
||||
**File:** `src/cli/mod.rs` -- Update `Sync` help text to mention the individual stage commands:
|
||||
|
||||
```rust
|
||||
/// Run full sync pipeline: ingest -> generate-docs -> embed
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore sync # Full pipeline: ingest + docs + embed
|
||||
lore sync --no-embed # Skip embedding step
|
||||
...
|
||||
|
||||
\x1b[1mIndividual stages:\x1b[0m
|
||||
lore ingest # Fetch from GitLab only
|
||||
lore generate-docs # Rebuild documents only
|
||||
lore embed # Re-embed only",
|
||||
help_heading = "Data Pipeline"
|
||||
)]
|
||||
Sync(SyncArgs),
|
||||
```
|
||||
|
||||
**File:** `src/app/robot_docs.rs` -- Add a `"hidden": true` field to the ingest/generate-docs/embed entries so agents know these are secondary:
|
||||
```rust
|
||||
"ingest": {
|
||||
"hidden": true,
|
||||
"description": "Sync data from GitLab (prefer 'sync' for full pipeline)",
|
||||
...
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore --help` no longer shows ingest, generate-docs, embed
|
||||
- `lore ingest`, `lore generate-docs`, `lore embed` all still work
|
||||
- `lore sync --help` mentions individual stage commands
|
||||
- `lore robot-docs` still includes all three (with `hidden: true`)
|
||||
|
||||
**Files touched:** `src/cli/mod.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 Commit Summary
|
||||
|
||||
**Commit A: Rename `stats` -> `index`**
|
||||
- `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
|
||||
|
||||
**Commit B: Merge `health` into `doctor`, hide pipeline stages**
|
||||
- `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
|
||||
|
||||
**Test plan:**
|
||||
```bash
|
||||
cargo check --all-targets
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
|
||||
# Rename verification
|
||||
lore index # Works (new name)
|
||||
lore stats # Works (hidden alias)
|
||||
lore index --check # Works
|
||||
|
||||
# Doctor merge verification
|
||||
lore doctor # Full diagnostic
|
||||
lore doctor --quick # Fast pre-flight
|
||||
lore health # Still works (hidden)
|
||||
|
||||
# Hidden stages verification
|
||||
lore --help # ingest/generate-docs/embed gone
|
||||
lore ingest # Still works
|
||||
lore sync --help # Mentions individual stages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Structural Consolidation (requires careful design)
|
||||
|
||||
These changes merge or absorb commands. More effort, more testing, but the biggest UX wins.
|
||||
|
||||
### P6: Consolidate `file-history` into `trace`
|
||||
|
||||
**Goal:** `trace` absorbs `file-history`. One command for file-centric intelligence.
|
||||
|
||||
**Approach:** Add `--mrs-only` flag to `trace`. When set, output matches `file-history` format (flat MR list, no issue/discussion linking). `file-history` becomes a hidden alias.
|
||||
|
||||
**File:** `src/cli/args.rs` -- Add flag to `TraceArgs`:
|
||||
|
||||
```rust
|
||||
pub struct TraceArgs {
|
||||
pub path: String,
|
||||
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
|
||||
#[arg(long, help_heading = "Output")]
|
||||
pub discussions: bool,
|
||||
|
||||
#[arg(long = "no-follow-renames", help_heading = "Filters")]
|
||||
pub no_follow_renames: bool,
|
||||
|
||||
#[arg(short = 'n', long = "limit", default_value = "20", help_heading = "Output")]
|
||||
pub limit: usize,
|
||||
|
||||
// NEW: absorb file-history behavior
|
||||
/// Show only MR list without issue/discussion linking (file-history mode)
|
||||
#[arg(long = "mrs-only", help_heading = "Output")]
|
||||
pub mrs_only: bool,
|
||||
|
||||
/// Only show merged MRs (file-history mode)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub merged: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/cli/mod.rs` -- Hide `FileHistory`:
|
||||
|
||||
```rust
|
||||
/// Show MRs that touched a file, with linked discussions
|
||||
#[command(name = "file-history", hide = true, help_heading = "File Analysis")]
|
||||
FileHistory(FileHistoryArgs),
|
||||
```
|
||||
|
||||
**File:** `src/app/handlers.rs` -- Route `trace --mrs-only` to the file-history handler:
|
||||
|
||||
```rust
|
||||
fn handle_trace(
|
||||
config_override: Option<&str>,
|
||||
args: TraceArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if args.mrs_only {
|
||||
// Delegate to file-history handler
|
||||
let fh_args = FileHistoryArgs {
|
||||
path: args.path,
|
||||
project: args.project,
|
||||
discussions: args.discussions,
|
||||
no_follow_renames: args.no_follow_renames,
|
||||
merged: args.merged,
|
||||
limit: args.limit,
|
||||
};
|
||||
return handle_file_history(config_override, fh_args, robot_mode);
|
||||
}
|
||||
// ... existing trace logic ...
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/app/robot_docs.rs` -- Update trace entry, mark file-history as deprecated:
|
||||
|
||||
```rust
|
||||
"trace": {
|
||||
"description": "Trace why code was introduced: file -> MR -> issue -> discussion. Use --mrs-only for flat MR listing.",
|
||||
"flags": ["<path>", "-p/--project", "--discussions", "--no-follow-renames", "-n/--limit", "--mrs-only", "--merged"],
|
||||
...
|
||||
},
|
||||
"file-history": {
|
||||
"hidden": true,
|
||||
"deprecated": "Use 'trace --mrs-only' instead",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- `lore trace src/main.rs` works unchanged
|
||||
- `lore trace src/main.rs --mrs-only` produces file-history output
|
||||
- `lore trace src/main.rs --mrs-only --merged` filters to merged MRs
|
||||
- `lore file-history src/main.rs` still works (hidden command)
|
||||
- `lore --help` shows only `trace` in File Analysis group
|
||||
|
||||
**Files touched:** `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
---
|
||||
|
||||
### P8: Make `count` a Flag on Entity Commands
|
||||
|
||||
**Goal:** `lore issues --count` replaces `lore count issues`. Standalone `count` becomes hidden.
|
||||
|
||||
**File:** `src/cli/args.rs` -- Add `--count` to `IssuesArgs`, `MrsArgs`, `NotesArgs`:
|
||||
|
||||
```rust
|
||||
// In IssuesArgs:
|
||||
/// Show count only (no listing)
|
||||
#[arg(long, help_heading = "Output", conflicts_with_all = ["iid", "open"])]
|
||||
pub count: bool,
|
||||
|
||||
// In MrsArgs:
|
||||
/// Show count only (no listing)
|
||||
#[arg(long, help_heading = "Output", conflicts_with_all = ["iid", "open"])]
|
||||
pub count: bool,
|
||||
|
||||
// In NotesArgs:
|
||||
/// Show count only (no listing)
|
||||
#[arg(long, help_heading = "Output", conflicts_with = "open")]
|
||||
pub count: bool,
|
||||
```
|
||||
|
||||
**File:** `src/app/handlers.rs` -- In `handle_issues`, `handle_mrs`, `handle_notes`, check the count flag early:
|
||||
|
||||
```rust
|
||||
// In handle_issues (pseudocode):
|
||||
if args.count {
|
||||
let count_args = CountArgs { entity: "issues".to_string(), for_entity: None };
|
||||
return handle_count(config_override, count_args, robot_mode).await;
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/cli/mod.rs` -- Hide `Count`:
|
||||
|
||||
```rust
|
||||
/// Count entities in local database
|
||||
#[command(hide = true, help_heading = "Query")]
|
||||
Count(CountArgs),
|
||||
```
|
||||
|
||||
**File:** `src/app/robot_docs.rs` -- Mark count as hidden, add `--count` documentation to issues/mrs/notes entries.
|
||||
|
||||
**Verification:**
|
||||
- `lore issues --count` returns issue count
|
||||
- `lore mrs --count` returns MR count
|
||||
- `lore notes --count` returns note count
|
||||
- `lore count issues` still works (hidden)
|
||||
- `lore count discussions --for mr` still works (no equivalent in the new pattern -- discussions/events/references still need the standalone `count` command)
|
||||
|
||||
**Important note:** `count` supports entity types that don't have their own command (discussions, events, references). The standalone `count` must remain functional (just hidden). The `--count` flag on `issues`/`mrs`/`notes` handles the common cases only.
|
||||
|
||||
**Files touched:** `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
---
|
||||
|
||||
### P10: Add `--sort` to `search`
|
||||
|
||||
**Goal:** Allow sorting search results by score, created date, or updated date.
|
||||
|
||||
**File:** `src/cli/args.rs` -- Add to `SearchArgs`:
|
||||
|
||||
```rust
|
||||
/// Sort results by field (score is default for ranked search)
|
||||
#[arg(long, value_parser = ["score", "created", "updated"], default_value = "score", help_heading = "Sorting")]
|
||||
pub sort: String,
|
||||
|
||||
/// Sort ascending (default: descending)
|
||||
#[arg(long, help_heading = "Sorting", overrides_with = "no_asc")]
|
||||
pub asc: bool,
|
||||
|
||||
#[arg(long = "no-asc", hide = true, overrides_with = "asc")]
|
||||
pub no_asc: bool,
|
||||
```
|
||||
|
||||
**File:** `src/cli/commands/search.rs` -- Thread the sort parameter through to the search query.
|
||||
|
||||
The search function currently returns results sorted by score. When `--sort created` or `--sort updated` is specified, apply an `ORDER BY` clause to the final result set.
|
||||
|
||||
**File:** `src/app/robot_docs.rs` -- Add `--sort` and `--asc` to the search command's flags list.
|
||||
|
||||
**Verification:**
|
||||
- `lore search 'auth' --sort score` (default, unchanged)
|
||||
- `lore search 'auth' --sort created --asc` (oldest first)
|
||||
- `lore search 'auth' --sort updated` (most recently updated first)
|
||||
|
||||
**Files touched:** `src/cli/args.rs`, `src/cli/commands/search.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 Commit Summary
|
||||
|
||||
**Commit C: Consolidate file-history into trace**
|
||||
- `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
**Commit D: Add `--count` flag to entity commands**
|
||||
- `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
**Commit E: Add `--sort` to search**
|
||||
- `src/cli/args.rs`, `src/cli/commands/search.rs`, `src/app/robot_docs.rs`
|
||||
|
||||
**Test plan:**
|
||||
```bash
|
||||
cargo check --all-targets
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
|
||||
# trace consolidation
|
||||
lore trace src/main.rs --mrs-only
|
||||
lore trace src/main.rs --mrs-only --merged --discussions
|
||||
lore file-history src/main.rs # backward compat
|
||||
|
||||
# count flag
|
||||
lore issues --count
|
||||
lore mrs --count -s opened
|
||||
lore notes --count --for-issue 42
|
||||
lore count discussions --for mr # still works
|
||||
|
||||
# search sort
|
||||
lore search 'auth' --sort created --asc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
After all implementation is complete:
|
||||
|
||||
### CLAUDE.md / AGENTS.md
|
||||
|
||||
Update the robot mode command reference to reflect:
|
||||
- `stats` -> `index` (with note that `stats` is a hidden alias)
|
||||
- `health` -> `doctor --quick` (with note that `health` is a hidden alias)
|
||||
- Remove `ingest`, `generate-docs`, `embed` from the primary command table (mention as "hidden, use `sync`")
|
||||
- Remove `file-history` from primary table (mention as "hidden, use `trace --mrs-only`")
|
||||
- Add `--count` flag to issues/mrs/notes documentation
|
||||
- Add `--sort` flag to search documentation
|
||||
- Add `--mrs-only` and `--merged` flags to trace documentation
|
||||
|
||||
### robot-docs Self-Discovery
|
||||
|
||||
The `robot_docs.rs` changes above handle this. Key points:
|
||||
- New `"hidden": true` field on deprecated/hidden commands
|
||||
- Updated descriptions mentioning canonical alternatives
|
||||
- Updated flags lists
|
||||
- Updated workflows section
|
||||
|
||||
---
|
||||
|
||||
## File Impact Summary
|
||||
|
||||
| File | Phase 1 | Phase 2 | Phase 3 | Total Changes |
|
||||
|------|---------|---------|---------|---------------|
|
||||
| `src/cli/mod.rs` | help_heading, drift value_parser | stats->index rename, hide health, hide pipeline stages | hide file-history, hide count | 4 passes |
|
||||
| `src/cli/args.rs` | singular/plural, remove `-f`, add `-o` | — | `--mrs-only`/`--merged` on trace, `--count` on entities, `--sort` on search | 2 passes |
|
||||
| `src/app/handlers.rs` | normalize entity strings | route doctor --quick | trace mrs-only delegation, count flag routing | 3 passes |
|
||||
| `src/app/robot_docs.rs` | update count flags | rename stats->index, merge health+doctor, add hidden field | update trace, file-history, count, search entries | 3 passes |
|
||||
| `src/cli/autocorrect.rs` | — | update CANONICAL_SUBCOMMANDS, SUBCOMMAND_ALIASES, COMMAND_FLAGS | — | 1 pass |
|
||||
| `src/main.rs` | — | stats->index variant rename, doctor variant change | — | 1 pass |
|
||||
| `src/cli/commands/search.rs` | — | — | sort parameter threading | 1 pass |
|
||||
|
||||
---
|
||||
|
||||
## Before / After Summary
|
||||
|
||||
### Command Count
|
||||
|
||||
| Metric | Before | After | Delta |
|
||||
|--------|--------|-------|-------|
|
||||
| Visible top-level commands | 29 | 21 | -8 (-28%) |
|
||||
| Hidden commands (functional) | 4 | 12 | +8 (absorbed) |
|
||||
| Stub/unimplemented commands | 2 | 2 | 0 |
|
||||
| Total functional commands | 33 | 33 | 0 (nothing lost) |
|
||||
|
||||
### `lore --help` Output
|
||||
|
||||
**Before (29 commands, flat list, ~50 lines of commands):**
|
||||
```
|
||||
Commands:
|
||||
issues List or show issues [aliases: issue]
|
||||
mrs List or show merge requests [aliases: mr]
|
||||
notes List notes from discussions [aliases: note]
|
||||
ingest Ingest data from GitLab
|
||||
count Count entities in local database
|
||||
status Show sync state [aliases: st]
|
||||
auth Verify GitLab authentication
|
||||
doctor Check environment health
|
||||
version Show version information
|
||||
init Initialize configuration and database
|
||||
search Search indexed documents [aliases: find]
|
||||
stats Show document and index statistics [aliases: stat]
|
||||
generate-docs Generate searchable documents from ingested data
|
||||
embed Generate vector embeddings for documents via Ollama
|
||||
sync Run full sync pipeline: ingest -> generate-docs -> embed
|
||||
migrate Run pending database migrations
|
||||
health Quick health check: config, database, schema version
|
||||
robot-docs Machine-readable command manifest for agent self-discovery
|
||||
completions Generate shell completions
|
||||
timeline Show a chronological timeline of events matching a query
|
||||
who People intelligence: experts, workload, active discussions, overlap
|
||||
me Personal work dashboard: open issues, authored/reviewing MRs, activity
|
||||
file-history Show MRs that touched a file, with linked discussions
|
||||
trace Trace why code was introduced: file -> MR -> issue -> discussion
|
||||
drift Detect discussion divergence from original intent
|
||||
related Find semantically related entities via vector search
|
||||
cron Manage cron-based automatic syncing
|
||||
token Manage stored GitLab token
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
```
|
||||
|
||||
**After (21 commands, grouped, ~35 lines of commands):**
|
||||
```
|
||||
Query:
|
||||
issues List or show issues [aliases: issue]
|
||||
mrs List or show merge requests [aliases: mr]
|
||||
notes List notes from discussions [aliases: note]
|
||||
search Search indexed documents [aliases: find]
|
||||
|
||||
Intelligence:
|
||||
timeline Chronological timeline of events
|
||||
who People intelligence: experts, workload, overlap
|
||||
me Personal work dashboard
|
||||
|
||||
File Analysis:
|
||||
trace Trace code provenance / file history
|
||||
related Find semantically related entities
|
||||
drift Detect discussion divergence
|
||||
|
||||
Data Pipeline:
|
||||
sync Run full sync pipeline
|
||||
|
||||
System:
|
||||
init Initialize configuration and database
|
||||
status Show sync state [aliases: st]
|
||||
doctor Check environment health (--quick for pre-flight)
|
||||
index Document and index statistics [aliases: idx]
|
||||
auth Verify GitLab authentication
|
||||
token Manage stored GitLab token
|
||||
migrate Run pending database migrations
|
||||
cron Manage automatic syncing
|
||||
robot-docs Agent self-discovery manifest
|
||||
completions Generate shell completions
|
||||
version Show version information
|
||||
```
|
||||
|
||||
### Flag Consistency
|
||||
|
||||
| Issue | Before | After |
|
||||
|-------|--------|-------|
|
||||
| `-f` collision (force vs for) | `ingest -f`=force, `count -f`=for | `-f` removed from count; `-f` = force everywhere |
|
||||
| Singular/plural entity types | `count issues` but `search --type issue` | Both forms accepted everywhere |
|
||||
| `notes --open` missing `-o` | `notes --open` (no shorthand) | `notes -o` works (matches issues/mrs) |
|
||||
| `search` missing `--sort` | No sort override | `--sort score\|created\|updated` + `--asc` |
|
||||
|
||||
### Naming Confusion
|
||||
|
||||
| Before | After | Resolution |
|
||||
|--------|-------|------------|
|
||||
| `status` vs `stats` vs `stat` (3 names, 2 commands) | `status` + `index` (2 names, 2 commands) | Eliminated near-homonym collision |
|
||||
| `health` vs `doctor` (2 commands, overlapping scope) | `doctor` + `doctor --quick` (1 command) | Progressive disclosure |
|
||||
| `trace` vs `file-history` (2 commands, overlapping function) | `trace` + `trace --mrs-only` (1 command) | Superset absorbs subset |
|
||||
|
||||
### Robot Ergonomics
|
||||
|
||||
| Metric | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Commands in robot-docs manifest | 29 | 21 visible + hidden section |
|
||||
| Agent decision space for "system check" | 4 commands | 2 commands (status, doctor) |
|
||||
| Agent decision space for "file query" | 3 commands + 2 who modes | 1 command (trace) + 2 who modes |
|
||||
| Entity type parse errors from singular/plural | Common | Eliminated |
|
||||
| Estimated token cost of robot-docs | Baseline | ~15% reduction (fewer entries, hidden flagged) |
|
||||
|
||||
### What Stays Exactly The Same
|
||||
|
||||
- All 33 functional commands remain callable (nothing is removed)
|
||||
- All existing flags and their behavior are preserved
|
||||
- All response schemas are unchanged
|
||||
- All exit codes are unchanged
|
||||
- The autocorrect system continues to work
|
||||
- All hidden/deprecated commands emit their existing warnings
|
||||
|
||||
### What Breaks (Intentional)
|
||||
|
||||
- `lore count -f mr` (the `-f` shorthand) -- must use `--for` instead
|
||||
- `lore --help` layout changes (commands are grouped, 8 commands hidden)
|
||||
- `lore robot-docs` output changes (new `hidden` field, renamed keys)
|
||||
- Any scripts parsing `--help` text (but `robot-docs` is the stable contract)
|
||||
@@ -1,5 +1,5 @@
|
||||
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.
|
||||
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 detail views. 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)
|
||||
|
||||
@@ -43,7 +43,7 @@ construct API calls without a separate project-ID lookup, even after path change
|
||||
**Back-compat rule**: Note payloads in the `notes` list command continue exposing `gitlab_id`
|
||||
for existing consumers, but **MUST also** expose `gitlab_note_id` with the same value. This
|
||||
ensures agents can use a single field name (`gitlab_note_id`) across all commands — `notes`,
|
||||
`show`, and `discussions --include-notes` — without special-casing by command.
|
||||
`issues <IID>`/`mrs <IID>`, and `discussions --include-notes` — without special-casing by command.
|
||||
|
||||
This contract exists so agents can deterministically construct `glab api` write calls without
|
||||
cross-referencing multiple commands. Each workstream below must satisfy these fields in its
|
||||
|
||||
@@ -107,12 +107,12 @@ Each criterion is independently testable. Implementation is complete when ALL pa
|
||||
|
||||
### AC-7: Show Issue Display (E2E)
|
||||
|
||||
**Human (`lore show issue 123`):**
|
||||
**Human (`lore issues 123`):**
|
||||
- [ ] New line after "State": `Status: In progress` (colored by `status_color` hex → nearest terminal color)
|
||||
- [ ] Status line only shown when `status_name IS NOT NULL`
|
||||
- [ ] Category shown in parens when available, lowercased: `Status: In progress (in_progress)`
|
||||
|
||||
**Robot (`lore --robot show issue 123`):**
|
||||
**Robot (`lore --robot issues 123`):**
|
||||
- [ ] JSON includes `status_name`, `status_category`, `status_color`, `status_icon_name`, `status_synced_at` fields
|
||||
- [ ] Fields are `null` (not absent) when status not available
|
||||
- [ ] `status_synced_at` is integer (ms epoch UTC) or `null` — enables freshness checks by consumers
|
||||
|
||||
701
specs/SPEC_explain.md
Normal file
701
specs/SPEC_explain.md
Normal file
@@ -0,0 +1,701 @@
|
||||
# Spec: lore explain — Auto-Generated Issue/MR Narratives
|
||||
|
||||
**Bead:** bd-9lbr
|
||||
**Created:** 2026-03-10
|
||||
|
||||
## Spec Status
|
||||
| Section | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Objective | complete | |
|
||||
| Tech Stack | complete | |
|
||||
| Project Structure | complete | |
|
||||
| Commands | complete | |
|
||||
| Code Style | complete | UX-audited: after_help, --sections, --since, --no-timeline, --max-decisions, singular types |
|
||||
| Boundaries | complete | |
|
||||
| Testing Strategy | complete | 13 test cases (7 original + 5 UX flags + 1 singular type) |
|
||||
| Git Workflow | complete | jj-first |
|
||||
| User Journeys | complete | 3 journeys covering agent, human, pipeline use |
|
||||
| Architecture | complete | ExplainParams + section filtering + time scoping |
|
||||
| Success Criteria | complete | 15 criteria (10 original + 5 UX flags) |
|
||||
| Non-Goals | complete | |
|
||||
| Tasks | complete | 5 tasks across 3 phases, all updated for UX flags |
|
||||
|
||||
**Definition of Complete:** All sections `complete`, Open Questions empty,
|
||||
every user journey has tasks, every task has TDD workflow and acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
- [Entity Detail] (Architecture): reuse show/ query patterns (private — copy, don't import)
|
||||
- [Timeline] (Architecture): import `crate::timeline::seed::seed_timeline_direct` + `collect_events`
|
||||
- [Events] (Architecture): new inline queries against resource_state_events/resource_label_events
|
||||
- [References] (Architecture): new query against entity_references table
|
||||
- [Discussions] (Architecture): adapted from show/ patterns, add resolved/resolvable filter
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (Resolve Before Implementation)
|
||||
<!-- All resolved -->
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
**Goal:** Add `lore explain issues N` / `lore explain mrs N` to auto-generate structured narratives of what happened on an issue or MR.
|
||||
|
||||
**Problem:** Understanding the full story of an issue/MR requires reading dozens of notes, cross-referencing state changes, checking related entities, and piecing together a timeline. This is time-consuming for humans and nearly impossible for AI agents without custom orchestration.
|
||||
|
||||
**Success metrics:**
|
||||
- Produces a complete narrative in <500ms for an issue with 50 notes
|
||||
- All 7 sections populated (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)
|
||||
- Works fully offline (no API calls, no LLM)
|
||||
- Deterministic and reproducible (same input = same output)
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack & Constraints
|
||||
|
||||
| Layer | Technology | Version |
|
||||
|-------|-----------|---------|
|
||||
| Language | Rust | nightly-2026-03-01 (rust-toolchain.toml) |
|
||||
| Framework | clap (derive) | As in Cargo.toml |
|
||||
| Database | SQLite via rusqlite | Bundled |
|
||||
| Testing | cargo test | Inline #[cfg(test)] |
|
||||
| Async | asupersync | 0.2 |
|
||||
|
||||
**Constraints:**
|
||||
- No LLM dependency — template-based, deterministic
|
||||
- No network calls — all data from local SQLite
|
||||
- Performance: <500ms for 50-note entity
|
||||
- Unsafe code forbidden (`#![forbid(unsafe_code)]`)
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/cli/commands/
|
||||
explain.rs # NEW: command module (queries, heuristic, result types)
|
||||
src/cli/
|
||||
mod.rs # EDIT: add Explain variant to Commands enum
|
||||
src/app/
|
||||
handlers.rs # EDIT: add handle_explain dispatch
|
||||
robot_docs.rs # EDIT: register explain in robot-docs manifest
|
||||
src/main.rs # EDIT: add Explain match arm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo check --all-targets
|
||||
|
||||
# Test
|
||||
cargo test explain
|
||||
|
||||
# Lint
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Format
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
**Command registration (from cli/mod.rs):**
|
||||
```rust
|
||||
/// Auto-generate a structured narrative of an issue or MR
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore explain issues 42 # Narrative for issue #42
|
||||
lore explain mrs 99 -p group/repo # Narrative for MR !99 in specific project
|
||||
lore -J explain issues 42 # JSON output for automation
|
||||
lore explain issues 42 --sections key_decisions,open_threads # Specific sections only
|
||||
lore explain issues 42 --since 30d # Narrative scoped to last 30 days
|
||||
lore explain issues 42 --no-timeline # Skip timeline (faster)")]
|
||||
Explain {
|
||||
/// Entity type: "issues" or "mrs" (singular forms also accepted)
|
||||
#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]
|
||||
entity_type: String,
|
||||
|
||||
/// Entity IID
|
||||
iid: i64,
|
||||
|
||||
/// Scope to project (fuzzy match)
|
||||
#[arg(short, long)]
|
||||
project: Option<String>,
|
||||
|
||||
/// Select specific sections (comma-separated)
|
||||
/// Valid: entity, description, key_decisions, activity, open_threads, related, timeline
|
||||
#[arg(long, value_delimiter = ',', help_heading = "Output")]
|
||||
sections: Option<Vec<String>>,
|
||||
|
||||
/// Skip timeline excerpt (faster execution)
|
||||
#[arg(long, help_heading = "Output")]
|
||||
no_timeline: bool,
|
||||
|
||||
/// Maximum key decisions to include
|
||||
#[arg(long, default_value = "10", help_heading = "Output")]
|
||||
max_decisions: usize,
|
||||
|
||||
/// Time scope for events/notes (e.g. 7d, 2w, 1m, or YYYY-MM-DD)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
since: Option<String>,
|
||||
},
|
||||
```
|
||||
|
||||
**Entity type normalization:** The handler must normalize singular forms: `"issue"` -> `"issues"`, `"mr"` -> `"mrs"`. This prevents common typos from causing errors.
|
||||
|
||||
**Query pattern (from show/issue.rs):**
|
||||
```rust
|
||||
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
|
||||
let project_id = resolve_project(conn, project_filter)?;
|
||||
let mut stmt = conn.prepare_cached("SELECT ... FROM issues WHERE iid = ?1 AND project_id = ?2")?;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Robot mode output (from cli/robot.rs):**
|
||||
```rust
|
||||
let response = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": result,
|
||||
"meta": { "elapsed_ms": elapsed.as_millis() }
|
||||
});
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Always (autonomous)
|
||||
- Run `cargo test explain` and `cargo clippy` after every code change
|
||||
- Follow existing query patterns from show/issue.rs and show/mr.rs
|
||||
- Use `resolve_project()` for project resolution (fuzzy match)
|
||||
- Cap key_decisions at `--max-decisions` (default 10), timeline_excerpt at 20 events
|
||||
- Normalize singular entity types (`issue` -> `issues`, `mr` -> `mrs`)
|
||||
- Respect `--sections` filter: omit unselected sections from output (both robot and human)
|
||||
- Respect `--since` filter: scope events/notes queries with `created_at >= ?` threshold
|
||||
|
||||
### Ask First (needs approval)
|
||||
- Adding new dependencies to Cargo.toml
|
||||
- Modifying existing query functions in show/ or timeline/
|
||||
- Changing the entity_references table schema
|
||||
|
||||
### Never (hard stops)
|
||||
- No LLM calls — explain must be deterministic
|
||||
- No API/network calls — fully offline
|
||||
- No new database migrations — use existing schema only
|
||||
- Do not modify show/ or timeline/ modules (copy patterns instead)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy (TDD — Red-Green)
|
||||
|
||||
**Methodology:** Test-Driven Development. Write tests first, confirm red, implement, confirm green.
|
||||
|
||||
**Framework:** cargo test, inline `#[cfg(test)]`
|
||||
**Location:** `src/cli/commands/explain.rs` (inline test module)
|
||||
|
||||
**Test categories:**
|
||||
- Unit tests: key-decisions heuristic, activity counting, description truncation
|
||||
- Integration tests: full explain pipeline with in-memory DB
|
||||
|
||||
**User journey test mapping:**
|
||||
| Journey | Test | Scenarios |
|
||||
|---------|------|-----------|
|
||||
| UJ-1: Agent explains issue | test_explain_issue_basic | All 7 sections present, robot JSON valid |
|
||||
| UJ-1: Agent explains MR | test_explain_mr | entity.type = "merge_request", merged_at included |
|
||||
| UJ-1: Singular entity type | test_explain_singular_entity_type | `"issue"` normalizes to `"issues"` |
|
||||
| UJ-1: Section filtering | test_explain_sections_filter_robot | Only selected sections in output |
|
||||
| UJ-1: No-timeline flag | test_explain_no_timeline_flag | timeline_excerpt is None |
|
||||
| UJ-2: Human reads narrative | (human render tested manually) | Headers, indentation, color |
|
||||
| UJ-3: Key decisions | test_explain_key_decision_heuristic | Note within 60min of state change by same actor |
|
||||
| UJ-3: No false decisions | test_explain_key_decision_ignores_unrelated_notes | Different author's note excluded |
|
||||
| UJ-3: Max decisions cap | test_explain_max_decisions | Respects `--max-decisions` parameter |
|
||||
| UJ-3: Since scopes events | test_explain_since_scopes_events | Only recent events included |
|
||||
| UJ-3: Open threads | test_explain_open_threads | Only unresolved discussions in output |
|
||||
| UJ-3: Edge case | test_explain_no_notes | Empty sections, no panic |
|
||||
| UJ-3: Activity counts | test_explain_activity_counts | Correct state/label/note counts |
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- **jj-first** — all VCS via jj, not git
|
||||
- **Commit format:** `feat(explain): <description>`
|
||||
- **No branches** — commit in place, use jj bookmarks to push
|
||||
|
||||
---
|
||||
|
||||
## User Journeys (Prioritized)
|
||||
|
||||
### P1 — Critical
|
||||
- **UJ-1: Agent queries issue/MR narrative**
|
||||
- Actor: AI agent (via robot mode)
|
||||
- Flow: `lore -J explain issues 42` → JSON with 7 sections → agent parses and acts
|
||||
- Error paths: Issue not found (exit 17), ambiguous project (exit 18)
|
||||
- Implemented by: Task 1, 2, 3, 4
|
||||
|
||||
### P2 — Important
|
||||
- **UJ-2: Human reads explain output**
|
||||
- Actor: Developer at terminal
|
||||
- Flow: `lore explain issues 42` → formatted narrative with headers, colors, indentation
|
||||
- Error paths: Same as UJ-1 but with human-readable error messages
|
||||
- Implemented by: Task 5
|
||||
|
||||
### P3 — Nice to Have
|
||||
- **UJ-3: Agent uses key-decisions to understand context**
|
||||
- Actor: AI agent making decisions
|
||||
- Flow: Parse `key_decisions` array → understand who decided what and when → inform action
|
||||
- Error paths: No key decisions found (empty array, not error)
|
||||
- Implemented by: Task 3
|
||||
|
||||
---
|
||||
|
||||
## Architecture / Data Model
|
||||
|
||||
### Data Assembly Pipeline (sync, no async needed)
|
||||
|
||||
```
|
||||
1. RESOLVE → resolve_project() + find entity by IID
|
||||
2. PARSE → normalize entity_type, parse --since, validate --sections
|
||||
3. DETAIL → entity metadata (title, state, author, labels, assignees, status)
|
||||
4. EVENTS → resource_state_events + resource_label_events (optionally --since scoped)
|
||||
5. NOTES → non-system notes via discussions join (optionally --since scoped)
|
||||
6. HEURISTIC → key_decisions = events correlated with notes by same actor within 60min
|
||||
7. THREADS → discussions WHERE resolvable=1 AND resolved=0
|
||||
8. REFERENCES → entity_references (both directions: source and target)
|
||||
9. TIMELINE → seed_timeline_direct + collect_events (capped at 20, skip if --no-timeline)
|
||||
10. FILTER → apply --sections filter: drop unselected sections before serialization
|
||||
11. ASSEMBLE → combine into ExplainResult
|
||||
```
|
||||
|
||||
**Section filtering:** When `--sections` is provided, only the listed sections are populated.
|
||||
Unselected sections are set to their zero-value (`None`, empty vec, etc.) and omitted
|
||||
from robot JSON via `#[serde(skip_serializing_if = "...")]`. The `entity` section is always
|
||||
included (needed for identification). Human mode skips rendering unselected sections.
|
||||
|
||||
**Time scoping:** When `--since` is provided, parse it using `crate::core::time::parse_since()`
|
||||
(same function used by timeline, me, file-history). Add `AND created_at >= ?` to events
|
||||
and notes queries. The entity header, references, and open threads are NOT time-scoped
|
||||
(they represent current state, not historical events).
|
||||
|
||||
### Key Types
|
||||
|
||||
```rust
|
||||
/// Parameters controlling explain behavior.
|
||||
pub struct ExplainParams {
|
||||
pub entity_type: String, // "issues" or "mrs" (already normalized)
|
||||
pub iid: i64,
|
||||
pub project: Option<String>,
|
||||
pub sections: Option<Vec<String>>, // None = all sections
|
||||
pub no_timeline: bool,
|
||||
pub max_decisions: usize, // default 10
|
||||
pub since: Option<i64>, // ms epoch threshold from --since parsing
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExplainResult {
|
||||
pub entity: EntitySummary,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description_excerpt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key_decisions: Option<Vec<KeyDecision>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activity: Option<ActivitySummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub open_threads: Option<Vec<OpenThread>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub related: Option<RelatedEntities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeline_excerpt: Option<Vec<TimelineEventSummary>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EntitySummary {
|
||||
#[serde(rename = "type")]
|
||||
pub entity_type: String, // "issue" or "merge_request"
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author: String,
|
||||
pub assignees: Vec<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub created_at: String, // ISO 8601
|
||||
pub updated_at: String, // ISO 8601
|
||||
pub url: Option<String>,
|
||||
pub status_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KeyDecision {
|
||||
pub timestamp: String, // ISO 8601
|
||||
pub actor: String,
|
||||
pub action: String, // "state: opened -> closed" or "label: +bug"
|
||||
pub context_note: String, // truncated to 500 chars
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActivitySummary {
|
||||
pub state_changes: usize,
|
||||
pub label_changes: usize,
|
||||
pub notes: usize, // non-system only
|
||||
pub first_event: Option<String>, // ISO 8601
|
||||
pub last_event: Option<String>, // ISO 8601
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OpenThread {
|
||||
pub discussion_id: String,
|
||||
pub started_by: String,
|
||||
pub started_at: String, // ISO 8601
|
||||
pub note_count: usize,
|
||||
pub last_note_at: String, // ISO 8601
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RelatedEntities {
|
||||
pub closing_mrs: Vec<ClosingMrInfo>,
|
||||
pub related_issues: Vec<RelatedEntityInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TimelineEventSummary {
|
||||
pub timestamp: String, // ISO 8601
|
||||
pub event_type: String,
|
||||
pub actor: Option<String>,
|
||||
pub summary: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Key Decisions Heuristic
|
||||
|
||||
The heuristic identifies notes that explain WHY state/label changes were made:
|
||||
|
||||
1. Collect all `resource_state_events` and `resource_label_events` for the entity
|
||||
2. Merge into unified chronological list with (timestamp, actor, description)
|
||||
3. For each event, find the FIRST non-system note by the SAME actor within 60 minutes AFTER the event
|
||||
4. Pair them as a `KeyDecision`
|
||||
5. Cap at `params.max_decisions` (default 10)
|
||||
|
||||
**SQL for state events:**
|
||||
```sql
|
||||
SELECT state, actor_username, created_at
|
||||
FROM resource_state_events
|
||||
WHERE issue_id = ?1 -- or merge_request_id = ?1
|
||||
AND (?2 IS NULL OR created_at >= ?2) -- --since filter
|
||||
ORDER BY created_at ASC
|
||||
```
|
||||
|
||||
**SQL for label events:**
|
||||
```sql
|
||||
SELECT action, label_name, actor_username, created_at
|
||||
FROM resource_label_events
|
||||
WHERE issue_id = ?1 -- or merge_request_id = ?1
|
||||
AND (?2 IS NULL OR created_at >= ?2) -- --since filter
|
||||
ORDER BY created_at ASC
|
||||
```
|
||||
|
||||
**SQL for non-system notes (for correlation):**
|
||||
```sql
|
||||
SELECT n.body, n.author_username, n.created_at
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
WHERE d.noteable_type = ?1 AND d.issue_id = ?2 -- or d.merge_request_id
|
||||
AND n.is_system = 0
|
||||
AND (?3 IS NULL OR n.created_at >= ?3) -- --since filter
|
||||
ORDER BY n.created_at ASC
|
||||
```
|
||||
|
||||
**Entity ID resolution:** The `discussions` table uses `issue_id` / `merge_request_id` columns (CHECK constraint: exactly one non-NULL). The `resource_state_events` and `resource_label_events` tables use the same pattern.
|
||||
|
||||
### Cross-References Query
|
||||
|
||||
```sql
|
||||
-- Outgoing references (this entity references others)
|
||||
SELECT target_entity_type, target_entity_id, target_project_path,
|
||||
target_entity_iid, reference_type, source_method
|
||||
FROM entity_references
|
||||
WHERE source_entity_type = ?1 AND source_entity_id = ?2
|
||||
|
||||
-- Incoming references (others reference this entity)
|
||||
SELECT source_entity_type, source_entity_id,
|
||||
reference_type, source_method
|
||||
FROM entity_references
|
||||
WHERE target_entity_type = ?1 AND target_entity_id = ?2
|
||||
```
|
||||
|
||||
**Note:** For closing MRs, reuse the pattern from show/issue.rs `get_closing_mrs()` which queries entity_references with `reference_type = 'closes'`.
|
||||
|
||||
### Open Threads Query
|
||||
|
||||
```sql
|
||||
SELECT d.gitlab_discussion_id, d.first_note_at, d.last_note_at
|
||||
FROM discussions d
|
||||
WHERE d.issue_id = ?1 -- or d.merge_request_id
|
||||
AND d.resolvable = 1
|
||||
AND d.resolved = 0
|
||||
ORDER BY d.last_note_at DESC
|
||||
```
|
||||
|
||||
Then for each discussion, fetch the first note's author:
|
||||
```sql
|
||||
SELECT author_username, created_at
|
||||
FROM notes
|
||||
WHERE discussion_id = ?1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
And count notes per discussion:
|
||||
```sql
|
||||
SELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0
|
||||
```
|
||||
|
||||
### Robot Mode Output Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entity": {
|
||||
"type": "issue", "iid": 3864, "title": "...", "state": "opened",
|
||||
"author": "teernisse", "assignees": ["teernisse"],
|
||||
"labels": ["customer:BNSF"], "created_at": "2026-01-10T...",
|
||||
"updated_at": "2026-02-12T...", "url": "...", "status_name": "In progress"
|
||||
},
|
||||
"description_excerpt": "First 500 chars...",
|
||||
"key_decisions": [{
|
||||
"timestamp": "2026-01-15T...",
|
||||
"actor": "teernisse",
|
||||
"action": "state: opened -> closed",
|
||||
"context_note": "Starting work on the integration..."
|
||||
}],
|
||||
"activity": {
|
||||
"state_changes": 3, "label_changes": 5, "notes": 42,
|
||||
"first_event": "2026-01-10T...", "last_event": "2026-02-12T..."
|
||||
},
|
||||
"open_threads": [{
|
||||
"discussion_id": "abc123",
|
||||
"started_by": "cseiber",
|
||||
"started_at": "2026-02-01T...",
|
||||
"note_count": 5,
|
||||
"last_note_at": "2026-02-10T..."
|
||||
}],
|
||||
"related": {
|
||||
"closing_mrs": [{ "iid": 200, "title": "...", "state": "merged" }],
|
||||
"related_issues": [{ "iid": 3800, "title": "Rail Break Card", "type": "related" }]
|
||||
},
|
||||
"timeline_excerpt": [
|
||||
{ "timestamp": "...", "event_type": "state_changed", "actor": "teernisse", "summary": "State changed to closed" }
|
||||
]
|
||||
},
|
||||
"meta": { "elapsed_ms": 350 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| # | Criterion | Input | Expected Output |
|
||||
|---|-----------|-------|----------------|
|
||||
| 1 | Issue explain produces all 7 sections | `lore -J explain issues N` | JSON with entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt |
|
||||
| 2 | MR explain produces all 7 sections | `lore -J explain mrs N` | Same shape, entity.type = "merge_request" |
|
||||
| 3 | Key decisions captures correlated notes | State change + note by same actor within 60min | KeyDecision with action + context_note |
|
||||
| 4 | Key decisions ignores unrelated notes | Note by different author near state change | Not in key_decisions array |
|
||||
| 5 | Open threads filters correctly | 2 discussions: 1 resolved, 1 unresolved | Only unresolved in open_threads |
|
||||
| 6 | Activity counts are accurate | 3 state events, 2 label events, 10 notes | Matching counts in activity section |
|
||||
| 7 | Performance | Issue with 50 notes | <500ms |
|
||||
| 8 | Entity not found | Non-existent IID | Exit code 17, suggestion to sync |
|
||||
| 9 | Ambiguous project | IID exists in multiple projects, no -p | Exit code 18, suggestion to use -p |
|
||||
| 10 | Human render | `lore explain issues N` (no -J) | Formatted narrative with headers |
|
||||
| 11 | Singular entity type accepted | `lore explain issue 42` | Same as `lore explain issues 42` |
|
||||
| 12 | Section filtering works | `--sections key_decisions,activity` | Only those 2 sections + entity in JSON |
|
||||
| 13 | No-timeline skips timeline | `--no-timeline` | timeline_excerpt absent, faster execution |
|
||||
| 14 | Max-decisions caps output | `--max-decisions 3` | At most 3 key_decisions |
|
||||
| 15 | Since scopes events/notes | `--since 30d` | Only events/notes from last 30 days in activity, key_decisions |
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **No LLM summarization** — This is template-based v1. LLM enhancement is a separate future feature.
|
||||
- **No new database migrations** — Uses existing schema (resource_state_events, resource_label_events, discussions, notes, entity_references tables all exist).
|
||||
- **No modification of show/ or timeline/ modules** — Copy patterns, don't refactor existing code. If we later want to share code, that's a separate refactoring bead.
|
||||
- **No interactive mode** — Output only, no prompts or follow-up questions.
|
||||
- **No MR diff analysis** — No file-level change summaries. Use `file-history` or `trace` for that.
|
||||
- **No assignee/reviewer history** — Activity summary counts events but doesn't track assignment changes over time.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Phase 1: Setup & Registration
|
||||
|
||||
- [ ] **Task 1:** Register explain command in CLI and wire dispatch
|
||||
- **Implements:** Infrastructure (UJ-1, UJ-2 prerequisite)
|
||||
- **Files:** `src/cli/mod.rs`, `src/cli/commands/mod.rs`, `src/main.rs`, `src/app/handlers.rs`, NEW `src/cli/commands/explain.rs`
|
||||
- **Depends on:** Nothing
|
||||
- **Test-first:**
|
||||
1. Write `test_explain_issue_basic` in explain.rs: insert a minimal issue + project + 1 discussion + 1 note + 1 state event into in-memory DB, call `run_explain()` with default ExplainParams, assert all 7 top-level sections present in result
|
||||
2. Write `test_explain_mr` in explain.rs: insert MR with merged_at, call `run_explain()`, assert `entity.type == "merge_request"` and merged_at is populated
|
||||
3. Write `test_explain_singular_entity_type`: call with `entity_type: "issue"`, assert it resolves same as `"issues"`
|
||||
4. Run tests — all must FAIL (red)
|
||||
5. Implement: Explain variant in Commands enum (with all flags: `--sections`, `--no-timeline`, `--max-decisions`, `--since`, singular entity type acceptance), handle_explain in handlers.rs (normalize entity_type, parse --since, build ExplainParams), skeleton `run_explain()` in explain.rs
|
||||
6. Run tests — all must PASS (green)
|
||||
- **Acceptance:** `cargo test explain::tests::test_explain_issue_basic`, `test_explain_mr`, and `test_explain_singular_entity_type` pass. Command registered in CLI help with after_help examples block.
|
||||
- **Implementation notes:**
|
||||
- Use inline args pattern (like Drift) with all flags from Code Style section
|
||||
- `entity_type` validated by `#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]`
|
||||
- Normalize in handler: `"issue"` -> `"issues"`, `"mr"` -> `"mrs"`
|
||||
- Parse `--since` using `crate::core::time::parse_since()` — returns ms epoch threshold
|
||||
- Validate `--sections` values against allowed set: `["entity", "description", "key_decisions", "activity", "open_threads", "related", "timeline"]`
|
||||
- Copy the `find_issue`/`find_mr` and `get_*` query patterns from show/issue.rs and show/mr.rs — they're private functions so can't be imported
|
||||
- Use `resolve_project()` from `crate::core::project` for project resolution
|
||||
- Use `ms_to_iso()` from `crate::core::time` for timestamp conversion
|
||||
|
||||
### Phase 2: Core Logic
|
||||
|
||||
- [ ] **Task 2:** Implement key-decisions heuristic
|
||||
- **Implements:** UJ-3
|
||||
- **Files:** `src/cli/commands/explain.rs`
|
||||
- **Depends on:** Task 1
|
||||
- **Test-first:**
|
||||
1. Write `test_explain_key_decision_heuristic`: insert state change event at T, insert note by SAME author at T+30min, call `extract_key_decisions()`, assert 1 decision with correct action + context_note
|
||||
2. Write `test_explain_key_decision_ignores_unrelated_notes`: insert state change by alice, insert note by bob at T+30min, assert 0 decisions
|
||||
3. Write `test_explain_key_decision_label_event`: insert label add event + correlated note, assert decision.action starts with "label: +"
|
||||
4. Run tests — all must FAIL (red)
|
||||
4. Write `test_explain_max_decisions`: insert 5 correlated event+note pairs, call with `max_decisions: 3`, assert exactly 3 decisions returned
|
||||
5. Write `test_explain_since_scopes_events`: insert event at T-60d and event at T-10d, call with `since: Some(T-30d)`, assert only recent event appears
|
||||
6. Run tests — all must FAIL (red)
|
||||
7. Implement `extract_key_decisions()` function:
|
||||
- Query resource_state_events and resource_label_events for entity (with optional `--since` filter)
|
||||
- Merge into unified chronological list
|
||||
- For each event, find first non-system note by same actor within 60min (notes also `--since` filtered)
|
||||
- Cap at `params.max_decisions`
|
||||
8. Run tests — all must PASS (green)
|
||||
- **Acceptance:** All 5 tests pass. Heuristic correctly correlates events with explanatory notes. `--max-decisions` and `--since` respected.
|
||||
- **Implementation notes:**
|
||||
- State events query: `SELECT state, actor_username, created_at FROM resource_state_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2) ORDER BY created_at`
|
||||
- Label events query: `SELECT action, label_name, actor_username, created_at FROM resource_label_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2) ORDER BY created_at`
|
||||
- Notes query: `SELECT n.body, n.author_username, n.created_at FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.{id_col} = ?1 AND n.is_system = 0 AND (?2 IS NULL OR n.created_at >= ?2) ORDER BY n.created_at`
|
||||
- The `{id_col}` is either `issue_id` or `merge_request_id` based on entity_type
|
||||
- Pass `params.since` (Option<i64>) as the `?2` parameter — NULL means no filter
|
||||
- Use `crate::core::time::ms_to_iso()` for timestamp conversion in output
|
||||
- Truncate context_note to 500 chars using `crate::cli::render::truncate()` or a local helper
|
||||
|
||||
- [ ] **Task 3:** Implement open threads, activity summary, and cross-references
|
||||
- **Implements:** UJ-1
|
||||
- **Files:** `src/cli/commands/explain.rs`
|
||||
- **Depends on:** Task 1
|
||||
- **Test-first:**
|
||||
1. Write `test_explain_open_threads`: insert 2 discussions (1 with resolved=0 resolvable=1, 1 with resolved=1 resolvable=1), assert only unresolved appears in open_threads
|
||||
2. Write `test_explain_activity_counts`: insert 3 state events + 2 label events + 10 non-system notes, assert activity.state_changes=3, label_changes=2, notes=10
|
||||
3. Write `test_explain_no_notes`: insert issue with zero notes and zero events, assert empty key_decisions, empty open_threads, activity all zeros, description_excerpt = "(no description)" if description is NULL
|
||||
4. Run tests — all must FAIL (red)
|
||||
5. Implement:
|
||||
- `fetch_open_threads()`: query discussions WHERE resolvable=1 AND resolved=0, fetch first note author + note count per thread
|
||||
- `build_activity_summary()`: count state events, label events, non-system notes, find min/max timestamps
|
||||
- `fetch_related_entities()`: query entity_references in both directions (source and target)
|
||||
- Description excerpt: first 500 chars of description, or "(no description)" if NULL
|
||||
6. Run tests — all must PASS (green)
|
||||
- **Acceptance:** All 3 tests pass. Open threads correctly filtered. Activity counts accurate. Empty entity handled gracefully.
|
||||
- **Implementation notes:**
|
||||
- Open threads query: `SELECT d.gitlab_discussion_id, d.first_note_at, d.last_note_at FROM discussions d WHERE d.{id_col} = ?1 AND d.resolvable = 1 AND d.resolved = 0 ORDER BY d.last_note_at DESC`
|
||||
- For first note author: `SELECT author_username FROM notes WHERE discussion_id = ?1 ORDER BY created_at ASC LIMIT 1`
|
||||
- For note count: `SELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0`
|
||||
- Cross-references: both outgoing and incoming from entity_references table
|
||||
- For closing MRs, reuse the query pattern from show/issue.rs `get_closing_mrs()`
|
||||
|
||||
- [ ] **Task 4:** Wire timeline excerpt using existing pipeline
|
||||
- **Implements:** UJ-1
|
||||
- **Files:** `src/cli/commands/explain.rs`
|
||||
- **Depends on:** Task 1
|
||||
- **Test-first:**
|
||||
1. Write `test_explain_timeline_excerpt`: insert issue + state events + notes, call run_explain() with `no_timeline: false`, assert timeline_excerpt is Some and non-empty and capped at 20 events
|
||||
2. Write `test_explain_no_timeline_flag`: call run_explain() with `no_timeline: true`, assert timeline_excerpt is None
|
||||
3. Run tests — both must FAIL (red)
|
||||
4. Implement: when `!params.no_timeline` and `--sections` includes "timeline" (or is None), call `seed_timeline_direct()` with entity type + IID, then `collect_events()`, convert first 20 TimelineEvents into TimelineEventSummary structs. Otherwise set timeline_excerpt to None.
|
||||
5. Run tests — both must PASS (green)
|
||||
- **Acceptance:** Timeline excerpt present with max 20 events when enabled. Skipped entirely when `--no-timeline`. Uses existing timeline pipeline (no reimplementation).
|
||||
- **Implementation notes:**
|
||||
- Import: `use crate::timeline::seed::seed_timeline_direct;` and `use crate::timeline::collect::collect_events;`
|
||||
- `seed_timeline_direct()` takes `(conn, entity_type, iid, project_id)` — verify exact signature before implementing
|
||||
- `collect_events()` returns `Vec<TimelineEvent>` — map to simplified `TimelineEventSummary` (timestamp, event_type string, actor, summary)
|
||||
- Timeline pipeline uses `EntityRef` struct from `crate::timeline::types` — needs entity's local DB id and project_path
|
||||
- Cap at 20 events: `events.truncate(20)` after collection
|
||||
- `--no-timeline` takes precedence over `--sections timeline` (if both specified, skip timeline)
|
||||
|
||||
### Phase 3: Output Rendering
|
||||
|
||||
- [ ] **Task 5:** Robot mode JSON output and human-readable rendering
|
||||
- **Implements:** UJ-1, UJ-2
|
||||
- **Files:** `src/cli/commands/explain.rs`, `src/app/robot_docs.rs`
|
||||
- **Depends on:** Task 1, 2, 3, 4
|
||||
- **Test-first:**
|
||||
1. Write `test_explain_robot_output_shape`: call run_explain() with all sections, serialize to JSON, assert all 7 top-level keys present
|
||||
2. Write `test_explain_sections_filter_robot`: call run_explain() with `sections: Some(vec!["key_decisions", "activity"])`, serialize, assert only `entity` + `key_decisions` + `activity` keys present (entity always included), assert `description_excerpt`, `open_threads`, `related`, `timeline_excerpt` are absent
|
||||
3. Run tests — both must FAIL (red)
|
||||
4. Implement:
|
||||
- Robot mode: `print_explain_json()` wrapping ExplainResult in `{"ok": true, "data": ..., "meta": {...}}` envelope. `#[serde(skip_serializing_if = "Option::is_none")]` on optional sections handles filtering automatically.
|
||||
- Human mode: `print_explain()` with section headers, colored output, indented key decisions, truncated descriptions. Check `params.sections` before rendering each section.
|
||||
- Register in robot-docs manifest (include `--sections`, `--no-timeline`, `--max-decisions`, `--since` flags)
|
||||
5. Run tests — both must PASS (green)
|
||||
- **Acceptance:** Robot JSON matches schema. Section filtering works in both robot and human mode. Command appears in `lore robot-docs`.
|
||||
- **Implementation notes:**
|
||||
- Robot envelope: use `serde_json::json!()` with `RobotMeta` from `crate::cli::robot`
|
||||
- Human rendering: use `Theme::bold()`, `Icons`, `render::truncate()` from `crate::cli::render`
|
||||
- Follow timeline.rs rendering pattern: header with entity info -> separator line -> sections
|
||||
- Register in robot_docs.rs following the existing pattern for other commands
|
||||
- Section filtering: the `run_explain()` function should already return None for unselected sections. The serializer skips them. Human renderer checks `is_some()` before rendering.
|
||||
|
||||
---
|
||||
|
||||
## Corrections from Original Bead
|
||||
|
||||
The bead (bd-9lbr) was created before a codebase rearchitecture. Key corrections:
|
||||
|
||||
1. **`src/core/events_db.rs` does not exist** — Event storage is in `src/ingestion/storage/events.rs` (insert only). Event queries are inline in `timeline/collect.rs`. Explain needs its own inline queries.
|
||||
|
||||
2. **`ResourceStateEvent` / `ResourceLabelEvent` structs don't exist** — The timeline queries raw rows directly. Explain should define lightweight local structs or use tuples.
|
||||
|
||||
3. **`run_show_issue()` / `run_show_mr()` are private** — They live in `include!()` files inside show/mod.rs. Cannot be imported. Copy the query patterns instead.
|
||||
|
||||
4. **bd-2g50 blocker is CLOSED** — `IssueDetail` already has `closed_at`, `references_full`, `user_notes_count`, `confidential`. No blocker.
|
||||
|
||||
5. **Clap registration pattern** — The bead shows args directly on the enum variant, which is correct for explain's simple args (matches Drift, Related pattern). No need for a separate ExplainArgs struct.
|
||||
|
||||
6. **entity_references has no fetch query** — Only `insert_entity_reference()` and `count_references_for_source()` exist. Explain needs a new SELECT query (inline in explain.rs).
|
||||
|
||||
---
|
||||
|
||||
## Session Log
|
||||
|
||||
### Session 1 — 2026-03-10
|
||||
- Read bead bd-9lbr thoroughly — exceptionally detailed but written before rearchitecture
|
||||
- Verified infrastructure: show/ (private functions, copy patterns), timeline/ (importable pipeline), events (inline SQL, no typed structs), xref (no fetch query), discussions (resolvable/resolved confirmed in migration 028)
|
||||
- Discovered bd-2g50 blocker is CLOSED — no dependency
|
||||
- Decided: two positional args (`lore explain issues N`) over single query syntax
|
||||
- Decided: formalize + gap-fill approach (bead is thorough, just needs updating)
|
||||
- Documented 6 corrections from original bead to current codebase state
|
||||
- Drafted complete spec with 5 tasks across 3 phases
|
||||
|
||||
### Session 1b — 2026-03-10 (CLI UX Audit)
|
||||
- Audited full CLI surface (30+ commands) against explain's proposed UX
|
||||
- Identified 8 improvements, user selected 6 to incorporate:
|
||||
1. **after_help examples block** — every other lore command has this, explain was missing it
|
||||
2. **--sections flag** — robot token efficiency, skip unselected sections entirely
|
||||
4. **Singular entity type tolerance** — accept `issue`/`mr` alongside `issues`/`mrs`
|
||||
5. **--no-timeline flag** — skip heaviest section for faster execution
|
||||
7. **--max-decisions N flag** — user control over key_decisions cap (default 10)
|
||||
8. **--since flag** — time-scope events/notes for long-lived entities
|
||||
- Skipped: #3 (command aliases ex/narrative), #6 (#42/!99 shorthand)
|
||||
- Updated: Code Style, Boundaries, Architecture (ExplainParams + ExplainResult types, section filtering, time scoping, SQL queries), Success Criteria (+5 new), Testing Strategy (+5 new tests), all 5 Tasks
|
||||
- ExplainResult sections now `Option<T>` with `skip_serializing_if` for section filtering
|
||||
- All sections remain complete — spec is ready for implementation
|
||||
@@ -7,6 +7,10 @@ struct FallbackErrorOutput {
|
||||
struct FallbackError {
|
||||
code: String,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
suggestion: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
actions: Vec<String>,
|
||||
}
|
||||
|
||||
fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
@@ -20,6 +24,8 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
error: FallbackError {
|
||||
code: "INTERNAL_ERROR".to_string(),
|
||||
message: gi_error.to_string(),
|
||||
suggestion: None,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
};
|
||||
serde_json::to_string(&fallback)
|
||||
@@ -59,6 +65,8 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
error: FallbackError {
|
||||
code: "INTERNAL_ERROR".to_string(),
|
||||
message: e.to_string(),
|
||||
suggestion: None,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
};
|
||||
eprintln!(
|
||||
|
||||
@@ -361,7 +361,7 @@ fn print_combined_ingest_json(
|
||||
notes_upserted: mrs.notes_upserted,
|
||||
},
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
|
||||
println!(
|
||||
@@ -735,7 +735,7 @@ async fn handle_init(
|
||||
}
|
||||
|
||||
let project_paths: Vec<String> = projects_flag
|
||||
.unwrap()
|
||||
.expect("validated: checked for None at lines 714-721")
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
@@ -743,8 +743,10 @@ async fn handle_init(
|
||||
|
||||
let result = run_init(
|
||||
InitInputs {
|
||||
gitlab_url: gitlab_url_flag.unwrap(),
|
||||
token_env_var: token_env_var_flag.unwrap(),
|
||||
gitlab_url: gitlab_url_flag
|
||||
.expect("validated: checked for None at lines 714-721"),
|
||||
token_env_var: token_env_var_flag
|
||||
.expect("validated: checked for None at lines 714-721"),
|
||||
project_paths,
|
||||
default_project: default_project_flag.clone(),
|
||||
},
|
||||
@@ -973,9 +975,7 @@ async fn handle_auth_test(
|
||||
name: result.name.clone(),
|
||||
gitlab_url: result.base_url.clone(),
|
||||
},
|
||||
meta: RobotMeta {
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else {
|
||||
@@ -1036,9 +1036,7 @@ async fn handle_doctor(
|
||||
success: result.success,
|
||||
checks: result.checks,
|
||||
},
|
||||
meta: RobotMeta {
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else {
|
||||
@@ -1083,9 +1081,7 @@ fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Some(git_hash)
|
||||
},
|
||||
},
|
||||
meta: RobotMeta {
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else if git_hash.is_empty() {
|
||||
@@ -1243,9 +1239,7 @@ async fn handle_migrate(
|
||||
after_version,
|
||||
migrated: after_version > before_version,
|
||||
},
|
||||
meta: RobotMeta {
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else if after_version > before_version {
|
||||
@@ -1326,7 +1320,7 @@ fn handle_file_history(
|
||||
|
||||
if robot_mode {
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
print_file_history_json(&result, elapsed_ms);
|
||||
print_file_history_json(&result, elapsed_ms)?;
|
||||
} else {
|
||||
print_file_history(&result);
|
||||
}
|
||||
@@ -1382,7 +1376,7 @@ fn handle_trace(
|
||||
|
||||
if robot_mode {
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
print_trace_json(&result, elapsed_ms, line_requested);
|
||||
print_trace_json(&result, elapsed_ms, line_requested)?;
|
||||
} else {
|
||||
print_trace(&result);
|
||||
}
|
||||
@@ -1960,9 +1954,7 @@ async fn handle_health(
|
||||
schema_version,
|
||||
actions,
|
||||
},
|
||||
meta: RobotMeta {
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else {
|
||||
|
||||
@@ -115,7 +115,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"description": "List or show issues",
|
||||
"description": "List issues, or view detail with <IID>",
|
||||
"flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "--status <name>", "-p/--project", "-a/--author", "-A/--assignee", "-l/--label", "-m/--milestone", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"],
|
||||
"example": "lore --robot issues --state opened --limit 10",
|
||||
"notes": {
|
||||
@@ -128,7 +128,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"data": {"issues": "[{iid:int, title:string, state:string, author_username:string, labels:[string], assignees:[string], discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, status_name:string?}]", "total_count": "int", "showing": "int"},
|
||||
"meta": {"elapsed_ms": "int", "available_statuses": "[string] — all distinct status names in the database, for use with --status filter"}
|
||||
},
|
||||
"show": {
|
||||
"detail": {
|
||||
"ok": "bool",
|
||||
"data": "IssueDetail (full entity with description, discussions, notes, events)",
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
@@ -138,7 +138,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]}
|
||||
},
|
||||
"mrs": {
|
||||
"description": "List or show merge requests",
|
||||
"description": "List merge requests, or view detail with <IID>",
|
||||
"flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "-p/--project", "-a/--author", "-A/--assignee", "-r/--reviewer", "-l/--label", "--since", "-d/--draft", "-D/--no-draft", "--target", "--source", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"],
|
||||
"example": "lore --robot mrs --state opened",
|
||||
"response_schema": {
|
||||
@@ -147,7 +147,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"data": {"mrs": "[{iid:int, title:string, state:string, author_username:string, labels:[string], draft:bool, target_branch:string, source_branch:string, discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, reviewers:[string]}]", "total_count": "int", "showing": "int"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
},
|
||||
"show": {
|
||||
"detail": {
|
||||
"ok": "bool",
|
||||
"data": "MrDetail (full entity with description, discussions, notes, events)",
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
@@ -316,6 +316,17 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"explain": {
|
||||
"description": "Auto-generate a structured narrative of an issue or MR",
|
||||
"flags": ["<entity_type: issues|mrs>", "<IID>", "-p/--project <path>", "--sections <comma-list>", "--no-timeline", "--max-decisions <N>", "--since <period>"],
|
||||
"valid_sections": ["entity", "description", "key_decisions", "activity", "open_threads", "related", "timeline"],
|
||||
"example": "lore --robot explain issues 42 --sections key_decisions,activity --since 30d",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"entity": "{type:string, iid:int, title:string, state:string, author:string, assignees:[string], labels:[string], created_at:string, updated_at:string, url:string?, status_name:string?}", "description_excerpt": "string?", "key_decisions": "[{timestamp:string, actor:string, action:string, context_note:string}]?", "activity": "{state_changes:int, label_changes:int, notes:int, first_event:string?, last_event:string?}?", "open_threads": "[{discussion_id:string, started_by:string, started_at:string, note_count:int, last_note_at:string}]?", "related": "{closing_mrs:[{iid:int, title:string, state:string, web_url:string?}], related_issues:[{entity_type:string, iid:int, title:string?, reference_type:string}]}?", "timeline_excerpt": "[{timestamp:string, event_type:string, actor:string?, summary:string}]?"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"description": "List notes from discussions with rich filtering",
|
||||
"flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--fields <list|minimal>", "--open"],
|
||||
@@ -371,7 +382,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"mentioned_in": "[{entity_type:string, project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, updated_at_iso:string, web_url:string?}]",
|
||||
"activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]"
|
||||
},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
"meta": {"elapsed_ms": "int", "gitlab_base_url": "string (GitLab instance URL for constructing entity links: {base_url}/{project}/-/issues/{iid})"}
|
||||
},
|
||||
"fields_presets": {
|
||||
"me_items_minimal": ["iid", "title", "attention_state", "attention_reason", "updated_at_iso"],
|
||||
@@ -385,7 +396,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"since_default": "1d for activity feed",
|
||||
"issue_filter": "Only In Progress / In Review status issues shown",
|
||||
"since_last_check": "Cursor-based inbox showing events since last run. Null on first run (no cursor yet). Groups events by entity (issue/MR). Sources: others' comments on your items, @mentions, assignment/review-request notes. Cursor auto-advances after each run. Use --reset-cursor to clear.",
|
||||
"cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_<username>.json. --project filters display only for since-last-check; cursor still advances for all projects for that user."
|
||||
"cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_<username>.json. --project filters display only for since-last-check; cursor still advances for all projects for that user.",
|
||||
"url_construction": "Use meta.gitlab_base_url + project + entity_type + iid to build links: {gitlab_base_url}/{project}/-/{issues|merge_requests}/{iid}"
|
||||
}
|
||||
},
|
||||
"robot-docs": {
|
||||
@@ -449,7 +461,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"17": "Not found",
|
||||
"18": "Ambiguous match",
|
||||
"19": "Health check failed",
|
||||
"20": "Config not found"
|
||||
"20": "Config not found",
|
||||
"21": "Embeddings not built"
|
||||
});
|
||||
|
||||
let workflows = serde_json::json!({
|
||||
@@ -780,42 +793,3 @@ async fn handle_list_compat(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_show_compat(
|
||||
config_override: Option<&str>,
|
||||
entity: &str,
|
||||
iid: i64,
|
||||
project_filter: Option<&str>,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
let project_filter = config.effective_project(project_filter);
|
||||
|
||||
match entity {
|
||||
"issue" => {
|
||||
let result = run_show_issue(&config, iid, project_filter)?;
|
||||
if robot_mode {
|
||||
print_show_issue_json(&result, start.elapsed().as_millis() as u64);
|
||||
} else {
|
||||
print_show_issue(&result);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"mr" => {
|
||||
let result = run_show_mr(&config, iid, project_filter)?;
|
||||
if robot_mode {
|
||||
print_show_mr_json(&result, start.elapsed().as_millis() as u64);
|
||||
} else {
|
||||
print_show_mr(&result);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::error().render(&format!("Unknown entity: {entity}"))
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,16 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
],
|
||||
),
|
||||
("drift", &["--threshold", "--project"]),
|
||||
(
|
||||
"explain",
|
||||
&[
|
||||
"--project",
|
||||
"--sections",
|
||||
"--no-timeline",
|
||||
"--max-decisions",
|
||||
"--since",
|
||||
],
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
&[
|
||||
@@ -290,7 +300,6 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--source-branch",
|
||||
],
|
||||
),
|
||||
("show", &["--project"]),
|
||||
("reset", &["--yes"]),
|
||||
(
|
||||
"me",
|
||||
@@ -389,6 +398,7 @@ const CANONICAL_SUBCOMMANDS: &[&str] = &[
|
||||
"file-history",
|
||||
"trace",
|
||||
"drift",
|
||||
"explain",
|
||||
"related",
|
||||
"cron",
|
||||
"token",
|
||||
@@ -396,7 +406,6 @@ const CANONICAL_SUBCOMMANDS: &[&str] = &[
|
||||
"backup",
|
||||
"reset",
|
||||
"list",
|
||||
"show",
|
||||
"auth-test",
|
||||
"sync-status",
|
||||
];
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
|
||||
},
|
||||
total: counts.total(),
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
|
||||
match serde_json::to_string(&output) {
|
||||
@@ -325,7 +325,7 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
|
||||
system_excluded: result.system_count,
|
||||
breakdown,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
|
||||
match serde_json::to_string(&output) {
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) {
|
||||
log_path: result.log_path.display().to_string(),
|
||||
replaced: result.replaced,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
@@ -128,7 +128,7 @@ pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64)
|
||||
action: "uninstall",
|
||||
was_installed: result.was_installed,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
@@ -284,7 +284,7 @@ pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) {
|
||||
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 },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
|
||||
@@ -468,7 +468,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
}
|
||||
|
||||
pub fn print_drift_json(response: &DriftResponse, elapsed_ms: u64) {
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let meta = RobotMeta::new(elapsed_ms);
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": response,
|
||||
|
||||
@@ -135,7 +135,7 @@ pub fn print_embed_json(result: &EmbedCommandResult, elapsed_ms: u64) {
|
||||
let output = EmbedJsonOutput {
|
||||
ok: true,
|
||||
data: result,
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
|
||||
1977
src/cli/commands/explain.rs
Normal file
1977
src/cli/commands/explain.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ 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::error::{LoreError, Result};
|
||||
use crate::core::file_history::resolve_rename_chain;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
@@ -391,7 +391,7 @@ pub fn print_file_history(result: &FileHistoryResult) {
|
||||
|
||||
// ── Robot (JSON) output ─────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
|
||||
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) -> Result<()> {
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
@@ -409,5 +409,10 @@ pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
|
||||
}
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&output)
|
||||
.map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ pub fn print_generate_docs_json(result: &GenerateDocsResult, elapsed_ms: u64) {
|
||||
unchanged: result.unchanged,
|
||||
errored: result.errored,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) {
|
||||
status_enrichment,
|
||||
status_enrichment_errors: result.status_enrichment_errors,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
|
||||
match serde_json::to_string(&output) {
|
||||
|
||||
@@ -370,7 +370,7 @@ pub fn print_list_mrs(result: &MrListResult) {
|
||||
|
||||
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
let json_result = MrListResultJson::from(result);
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let meta = RobotMeta::new(elapsed_ms);
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": json_result,
|
||||
|
||||
@@ -193,7 +193,7 @@ pub fn print_list_notes(result: &NoteListResult) {
|
||||
|
||||
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 meta = RobotMeta::new(elapsed_ms);
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": json_result,
|
||||
|
||||
@@ -627,6 +627,115 @@ fn activity_is_own_flag() {
|
||||
assert!(results[0].is_own);
|
||||
}
|
||||
|
||||
// ─── Activity on Closed/Merged Items ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn activity_note_on_merged_authored_mr() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_mr(&conn, 10, 1, 99, "alice", "merged", 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,
|
||||
"follow-up question",
|
||||
t,
|
||||
);
|
||||
|
||||
let results = query_activity(&conn, "alice", &[], 0).unwrap();
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
1,
|
||||
"should see activity on merged MR authored by user"
|
||||
);
|
||||
assert_eq!(results[0].entity_iid, 99);
|
||||
assert_eq!(results[0].entity_type, "mr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_note_on_closed_mr_as_reviewer() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_mr(&conn, 10, 1, 99, "bob", "closed", 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", false, "can you re-check?", t);
|
||||
|
||||
let results = query_activity(&conn, "alice", &[], 0).unwrap();
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
1,
|
||||
"should see activity on closed MR where user is reviewer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_note_on_closed_assigned_issue() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
|
||||
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,
|
||||
"reopening discussion",
|
||||
t,
|
||||
);
|
||||
|
||||
let results = query_activity(&conn, "alice", &[], 0).unwrap();
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
1,
|
||||
"should see activity on closed issue assigned to user"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_includes_comment_on_merged_mr() {
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_mr(&conn, 10, 1, 99, "alice", "merged", 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,
|
||||
"post-merge question",
|
||||
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,
|
||||
"should see others' comments on merged MR in inbox"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Assignment Detection Tests (Task #12) ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
|
||||
if robot_mode {
|
||||
let fields = args.fields.as_deref();
|
||||
render_robot::print_me_json(&dashboard, elapsed_ms, fields)?;
|
||||
render_robot::print_me_json(&dashboard, elapsed_ms, fields, &config.gitlab.base_url)?;
|
||||
} else if show_all {
|
||||
render_human::print_me_dashboard(&dashboard, single_project);
|
||||
} else {
|
||||
|
||||
@@ -362,19 +362,18 @@ pub fn query_activity(
|
||||
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.
|
||||
// These ensure we only see activity on items associated with the user,
|
||||
// regardless of state (open, closed, or merged). Comments on merged MRs
|
||||
// and closed issues are still relevant (follow-up discussions, post-merge
|
||||
// questions, etc.).
|
||||
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'
|
||||
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
|
||||
)";
|
||||
let my_mr_check = "(
|
||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
|
||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
|
||||
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
||||
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')
|
||||
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
|
||||
)";
|
||||
|
||||
// Source 1: Human comments on my items
|
||||
@@ -574,7 +573,7 @@ struct RawSinceCheckRow {
|
||||
|
||||
/// Query actionable events from others since `cursor_ms`.
|
||||
/// Returns events from three sources:
|
||||
/// 1. Others' comments on my open items
|
||||
/// 1. Others' comments on my items (any state)
|
||||
/// 2. @mentions on any item (not restricted to my items)
|
||||
/// 3. Assignment/review-request system notes mentioning me
|
||||
pub fn query_since_last_check(
|
||||
@@ -583,19 +582,18 @@ pub fn query_since_last_check(
|
||||
cursor_ms: i64,
|
||||
) -> Result<Vec<SinceCheckGroup>> {
|
||||
// Build the "my items" subquery fragments (reused from activity).
|
||||
// No state filter: comments on closed/merged items are still actionable.
|
||||
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'
|
||||
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
|
||||
)";
|
||||
let my_mr_check = "(
|
||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
|
||||
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
|
||||
OR EXISTS (SELECT 1 FROM mr_reviewers rv
|
||||
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')
|
||||
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
|
||||
)";
|
||||
|
||||
// Source 1: Others' comments on my open items
|
||||
// Source 1: Others' comments on my items (any state)
|
||||
let source1 = format!(
|
||||
"SELECT n.created_at, 'note',
|
||||
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
|
||||
|
||||
@@ -15,11 +15,12 @@ pub fn print_me_json(
|
||||
dashboard: &MeDashboard,
|
||||
elapsed_ms: u64,
|
||||
fields: Option<&[String]>,
|
||||
gitlab_base_url: &str,
|
||||
) -> crate::core::error::Result<()> {
|
||||
let envelope = MeJsonEnvelope {
|
||||
ok: true,
|
||||
data: MeDataJson::from_dashboard(dashboard),
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::with_base_url(elapsed_ms, gitlab_base_url),
|
||||
};
|
||||
|
||||
let mut value = serde_json::to_value(&envelope)
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod cron;
|
||||
pub mod doctor;
|
||||
pub mod drift;
|
||||
pub mod embed;
|
||||
pub mod explain;
|
||||
pub mod file_history;
|
||||
pub mod generate_docs;
|
||||
pub mod ingest;
|
||||
@@ -35,6 +36,7 @@ pub use cron::{
|
||||
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 explain::{handle_explain, print_explain, print_explain_json, run_explain};
|
||||
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::{
|
||||
|
||||
@@ -558,7 +558,7 @@ pub fn print_related_human(response: &RelatedResponse) {
|
||||
}
|
||||
|
||||
pub fn print_related_json(response: &RelatedResponse, elapsed_ms: u64) {
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let meta = RobotMeta::new(elapsed_ms);
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": response,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct DiscussionDetail {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteDetail {
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
@@ -277,7 +278,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut note_stmt = conn.prepare(
|
||||
"SELECT author_username, body, created_at, is_system
|
||||
"SELECT gitlab_id, author_username, body, created_at, is_system
|
||||
FROM notes
|
||||
WHERE discussion_id = ?
|
||||
ORDER BY position",
|
||||
@@ -287,11 +288,12 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
|
||||
for (disc_id, individual_note) in disc_rows {
|
||||
let notes: Vec<NoteDetail> = note_stmt
|
||||
.query_map([disc_id], |row| {
|
||||
let is_system: i64 = row.get(3)?;
|
||||
let is_system: i64 = row.get(4)?;
|
||||
Ok(NoteDetail {
|
||||
author_username: row.get(0)?,
|
||||
body: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
gitlab_id: row.get(0)?,
|
||||
author_username: row.get(1)?,
|
||||
body: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
is_system: is_system == 1,
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct MrDiscussionDetail {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MrNoteDetail {
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
@@ -224,7 +225,7 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut note_stmt = conn.prepare(
|
||||
"SELECT author_username, body, created_at, is_system,
|
||||
"SELECT gitlab_id, author_username, body, created_at, is_system,
|
||||
position_old_path, position_new_path, position_old_line,
|
||||
position_new_line, position_type
|
||||
FROM notes
|
||||
@@ -236,12 +237,12 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
|
||||
for (disc_id, individual_note) in disc_rows {
|
||||
let notes: Vec<MrNoteDetail> = note_stmt
|
||||
.query_map([disc_id], |row| {
|
||||
let is_system: i64 = row.get(3)?;
|
||||
let old_path: Option<String> = row.get(4)?;
|
||||
let new_path: Option<String> = row.get(5)?;
|
||||
let old_line: Option<i64> = row.get(6)?;
|
||||
let new_line: Option<i64> = row.get(7)?;
|
||||
let position_type: Option<String> = row.get(8)?;
|
||||
let is_system: i64 = row.get(4)?;
|
||||
let old_path: Option<String> = row.get(5)?;
|
||||
let new_path: Option<String> = row.get(6)?;
|
||||
let old_line: Option<i64> = row.get(7)?;
|
||||
let new_line: Option<i64> = row.get(8)?;
|
||||
let position_type: Option<String> = row.get(9)?;
|
||||
|
||||
let position = if old_path.is_some()
|
||||
|| new_path.is_some()
|
||||
@@ -260,9 +261,10 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
|
||||
};
|
||||
|
||||
Ok(MrNoteDetail {
|
||||
author_username: row.get(0)?,
|
||||
body: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
gitlab_id: row.get(0)?,
|
||||
author_username: row.get(1)?,
|
||||
body: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
is_system: is_system == 1,
|
||||
position,
|
||||
})
|
||||
|
||||
@@ -398,6 +398,7 @@ pub struct DiscussionDetailJson {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteDetailJson {
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
pub body: String,
|
||||
pub created_at: String,
|
||||
@@ -458,6 +459,7 @@ impl From<&DiscussionDetail> for DiscussionDetailJson {
|
||||
impl From<&NoteDetail> for NoteDetailJson {
|
||||
fn from(note: &NoteDetail) -> Self {
|
||||
Self {
|
||||
gitlab_id: note.gitlab_id,
|
||||
author_username: note.author_username.clone(),
|
||||
body: note.body.clone(),
|
||||
created_at: ms_to_iso(note.created_at),
|
||||
@@ -497,6 +499,7 @@ pub struct MrDiscussionDetailJson {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MrNoteDetailJson {
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
pub body: String,
|
||||
pub created_at: String,
|
||||
@@ -542,6 +545,7 @@ impl From<&MrDiscussionDetail> for MrDiscussionDetailJson {
|
||||
impl From<&MrNoteDetail> for MrNoteDetailJson {
|
||||
fn from(note: &MrNoteDetail) -> Self {
|
||||
Self {
|
||||
gitlab_id: note.gitlab_id,
|
||||
author_username: note.author_username.clone(),
|
||||
body: note.body.clone(),
|
||||
created_at: ms_to_iso(note.created_at),
|
||||
@@ -553,7 +557,7 @@ impl From<&MrNoteDetail> for MrNoteDetailJson {
|
||||
|
||||
pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
|
||||
let json_result = IssueDetailJson::from(issue);
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let meta = RobotMeta::new(elapsed_ms);
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": json_result,
|
||||
@@ -567,7 +571,7 @@ pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
|
||||
|
||||
pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) {
|
||||
let json_result = MrDetailJson::from(mr);
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let meta = RobotMeta::new(elapsed_ms);
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": json_result,
|
||||
|
||||
@@ -583,7 +583,7 @@ pub fn print_stats_json(result: &StatsResult, elapsed_ms: u64) {
|
||||
}),
|
||||
}),
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
|
||||
@@ -313,7 +313,7 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
|
||||
system_notes: result.summary.system_note_count,
|
||||
},
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
|
||||
match serde_json::to_string(&output) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::trace::{TraceChain, TraceResult};
|
||||
|
||||
/// Parse a path with optional `:line` suffix.
|
||||
@@ -152,7 +153,11 @@ fn truncate_body(body: &str, max: usize) -> String {
|
||||
format!("{}...", &body[..boundary])
|
||||
}
|
||||
|
||||
pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: Option<u32>) {
|
||||
pub fn print_trace_json(
|
||||
result: &TraceResult,
|
||||
elapsed_ms: u64,
|
||||
line_requested: Option<u32>,
|
||||
) -> Result<()> {
|
||||
// Truncate discussion bodies for token efficiency in robot mode
|
||||
let chains: Vec<serde_json::Value> = result
|
||||
.trace_chains
|
||||
@@ -205,7 +210,12 @@ pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: O
|
||||
}
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&output)
|
||||
.map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -376,7 +376,7 @@ pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
|
||||
resolved_input,
|
||||
result: data,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
meta: RobotMeta::new(elapsed_ms),
|
||||
};
|
||||
|
||||
let mut value = serde_json::to_value(&output).unwrap_or_else(|e| {
|
||||
|
||||
@@ -277,6 +277,44 @@ pub enum Commands {
|
||||
/// Trace why code was introduced: file -> MR -> issue -> discussion
|
||||
Trace(TraceArgs),
|
||||
|
||||
/// Auto-generate a structured narrative of an issue or MR
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore explain issues 42 # Narrative for issue #42
|
||||
lore explain mrs 99 -p group/repo # Narrative for MR !99 in specific project
|
||||
lore -J explain issues 42 # JSON output for automation
|
||||
lore explain issues 42 --sections key_decisions,open_threads # Specific sections only
|
||||
lore explain issues 42 --since 30d # Narrative scoped to last 30 days
|
||||
lore explain issues 42 --no-timeline # Skip timeline (faster)")]
|
||||
Explain {
|
||||
/// Entity type: "issues" or "mrs" (singular forms also accepted)
|
||||
#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]
|
||||
entity_type: String,
|
||||
|
||||
/// Entity IID
|
||||
iid: i64,
|
||||
|
||||
/// Scope to project (fuzzy match)
|
||||
#[arg(short, long)]
|
||||
project: Option<String>,
|
||||
|
||||
/// Select specific sections (comma-separated)
|
||||
/// Valid: entity, description, key_decisions, activity, open_threads, related, timeline
|
||||
#[arg(long, value_delimiter = ',', help_heading = "Output")]
|
||||
sections: Option<Vec<String>>,
|
||||
|
||||
/// Skip timeline excerpt (faster execution)
|
||||
#[arg(long, help_heading = "Output")]
|
||||
no_timeline: bool,
|
||||
|
||||
/// Maximum key decisions to include
|
||||
#[arg(long, default_value = "10", help_heading = "Output")]
|
||||
max_decisions: usize,
|
||||
|
||||
/// Time scope for events/notes (e.g. 7d, 2w, 1m, or YYYY-MM-DD)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
since: Option<String>,
|
||||
},
|
||||
|
||||
/// Detect discussion divergence from original intent
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore drift issues 42 # Check drift on issue #42
|
||||
@@ -381,17 +419,6 @@ pub enum Commands {
|
||||
source_branch: Option<String>,
|
||||
},
|
||||
|
||||
#[command(hide = true)]
|
||||
Show {
|
||||
#[arg(value_parser = ["issue", "mr"])]
|
||||
entity: String,
|
||||
|
||||
iid: i64,
|
||||
|
||||
#[arg(long)]
|
||||
project: Option<String>,
|
||||
},
|
||||
|
||||
#[command(hide = true, name = "auth-test")]
|
||||
AuthTest,
|
||||
|
||||
|
||||
@@ -3,6 +3,26 @@ use serde::Serialize;
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RobotMeta {
|
||||
pub elapsed_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gitlab_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl RobotMeta {
|
||||
/// Standard meta with timing only.
|
||||
pub fn new(elapsed_ms: u64) -> Self {
|
||||
Self {
|
||||
elapsed_ms,
|
||||
gitlab_base_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Meta with GitLab base URL for URL construction by consumers.
|
||||
pub fn with_base_url(elapsed_ms: u64, base_url: &str) -> Self {
|
||||
Self {
|
||||
elapsed_ms,
|
||||
gitlab_base_url: Some(base_url.trim_end_matches('/').to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter JSON object fields in-place for `--fields` support.
|
||||
@@ -133,4 +153,27 @@ mod tests {
|
||||
let expanded = expand_fields_preset(&fields, "notes");
|
||||
assert_eq!(expanded, ["id", "body"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_new_omits_base_url() {
|
||||
let meta = RobotMeta::new(42);
|
||||
let json = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(json["elapsed_ms"], 42);
|
||||
assert!(json.get("gitlab_base_url").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_with_base_url_includes_it() {
|
||||
let meta = RobotMeta::with_base_url(99, "https://gitlab.example.com");
|
||||
let json = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(json["elapsed_ms"], 99);
|
||||
assert_eq!(json["gitlab_base_url"], "https://gitlab.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_with_base_url_strips_trailing_slash() {
|
||||
let meta = RobotMeta::with_base_url(0, "https://gitlab.example.com/");
|
||||
let json = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(json["gitlab_base_url"], "https://gitlab.example.com");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,11 @@ pub enum ErrorCode {
|
||||
OllamaUnavailable,
|
||||
OllamaModelNotFound,
|
||||
EmbeddingFailed,
|
||||
EmbeddingsNotBuilt,
|
||||
NotFound,
|
||||
Ambiguous,
|
||||
HealthCheckFailed,
|
||||
UsageError,
|
||||
SurgicalPreflightFailed,
|
||||
}
|
||||
|
||||
@@ -52,8 +55,11 @@ impl std::fmt::Display for ErrorCode {
|
||||
Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE",
|
||||
Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND",
|
||||
Self::EmbeddingFailed => "EMBEDDING_FAILED",
|
||||
Self::EmbeddingsNotBuilt => "EMBEDDINGS_NOT_BUILT",
|
||||
Self::NotFound => "NOT_FOUND",
|
||||
Self::Ambiguous => "AMBIGUOUS",
|
||||
Self::HealthCheckFailed => "HEALTH_CHECK_FAILED",
|
||||
Self::UsageError => "USAGE_ERROR",
|
||||
Self::SurgicalPreflightFailed => "SURGICAL_PREFLIGHT_FAILED",
|
||||
};
|
||||
write!(f, "{code}")
|
||||
@@ -79,8 +85,11 @@ impl ErrorCode {
|
||||
Self::OllamaUnavailable => 14,
|
||||
Self::OllamaModelNotFound => 15,
|
||||
Self::EmbeddingFailed => 16,
|
||||
Self::EmbeddingsNotBuilt => 21,
|
||||
Self::NotFound => 17,
|
||||
Self::Ambiguous => 18,
|
||||
Self::HealthCheckFailed => 19,
|
||||
Self::UsageError => 2,
|
||||
// Shares exit code 6 with GitLabNotFound — same semantic category (resource not found).
|
||||
// Robot consumers distinguish via ErrorCode string, not exit code.
|
||||
Self::SurgicalPreflightFailed => 6,
|
||||
@@ -201,7 +210,7 @@ impl LoreError {
|
||||
Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable,
|
||||
Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound,
|
||||
Self::EmbeddingFailed { .. } => ErrorCode::EmbeddingFailed,
|
||||
Self::EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed,
|
||||
Self::EmbeddingsNotBuilt => ErrorCode::EmbeddingsNotBuilt,
|
||||
Self::SurgicalPreflightFailed { .. } => ErrorCode::SurgicalPreflightFailed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
pub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;
|
||||
|
||||
pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
|
||||
assert!(
|
||||
(0..CHUNK_ROWID_MULTIPLIER).contains(&chunk_index),
|
||||
"chunk_index {chunk_index} out of range [0, {CHUNK_ROWID_MULTIPLIER})"
|
||||
);
|
||||
document_id
|
||||
.checked_mul(CHUNK_ROWID_MULTIPLIER)
|
||||
.and_then(|v| v.checked_add(chunk_index))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("encode_rowid overflow: document_id={document_id}, chunk_index={chunk_index}")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
|
||||
assert!(
|
||||
rowid >= 0,
|
||||
"decode_rowid called with negative rowid: {rowid}"
|
||||
);
|
||||
let document_id = rowid / CHUNK_ROWID_MULTIPLIER;
|
||||
let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;
|
||||
(document_id, chunk_index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encode_single_chunk() {
|
||||
assert_eq!(encode_rowid(1, 0), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_multi_chunk() {
|
||||
assert_eq!(encode_rowid(1, 5), 1005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_specific_values() {
|
||||
assert_eq!(encode_rowid(42, 0), 42000);
|
||||
assert_eq!(encode_rowid(42, 5), 42005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_zero_chunk() {
|
||||
assert_eq!(decode_rowid(42000), (42, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_roundtrip() {
|
||||
for doc_id in [0, 1, 42, 100, 999, 10000] {
|
||||
for chunk_idx in [0, 1, 5, 99, 999] {
|
||||
let rowid = encode_rowid(doc_id, chunk_idx);
|
||||
let (decoded_doc, decoded_chunk) = decode_rowid(rowid);
|
||||
assert_eq!(
|
||||
(decoded_doc, decoded_chunk),
|
||||
(doc_id, chunk_idx),
|
||||
"Roundtrip failed for doc_id={doc_id}, chunk_idx={chunk_idx}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiplier_value() {
|
||||
assert_eq!(CHUNK_ROWID_MULTIPLIER, 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
pub const CHUNK_MAX_BYTES: usize = 1_500;
|
||||
|
||||
pub const EXPECTED_DIMS: usize = 768;
|
||||
|
||||
pub const CHUNK_OVERLAP_CHARS: usize = 200;
|
||||
|
||||
pub fn split_into_chunks(content: &str) -> Vec<(usize, String)> {
|
||||
if content.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if content.len() <= CHUNK_MAX_BYTES {
|
||||
return vec![(0, content.to_string())];
|
||||
}
|
||||
|
||||
let mut chunks: Vec<(usize, String)> = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut chunk_index = 0;
|
||||
|
||||
while start < content.len() {
|
||||
let remaining = &content[start..];
|
||||
if remaining.len() <= CHUNK_MAX_BYTES {
|
||||
chunks.push((chunk_index, remaining.to_string()));
|
||||
break;
|
||||
}
|
||||
|
||||
let end = floor_char_boundary(content, start + CHUNK_MAX_BYTES);
|
||||
let window = &content[start..end];
|
||||
|
||||
let split_at = find_paragraph_break(window)
|
||||
.or_else(|| find_sentence_break(window))
|
||||
.or_else(|| find_word_break(window))
|
||||
.unwrap_or(window.len());
|
||||
|
||||
let chunk_text = &content[start..start + split_at];
|
||||
chunks.push((chunk_index, chunk_text.to_string()));
|
||||
|
||||
let advance = if split_at > CHUNK_OVERLAP_CHARS {
|
||||
split_at - CHUNK_OVERLAP_CHARS
|
||||
} else {
|
||||
split_at
|
||||
}
|
||||
.max(1);
|
||||
let old_start = start;
|
||||
start += advance;
|
||||
// Ensure start lands on a char boundary after overlap subtraction
|
||||
start = floor_char_boundary(content, start);
|
||||
// Guarantee forward progress: multi-byte chars can cause
|
||||
// floor_char_boundary to round back to old_start
|
||||
if start <= old_start {
|
||||
start = old_start
|
||||
+ content[old_start..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(1, |c| c.len_utf8());
|
||||
}
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn find_paragraph_break(window: &str) -> Option<usize> {
|
||||
let search_start = floor_char_boundary(window, window.len() * 2 / 3);
|
||||
window[search_start..]
|
||||
.rfind("\n\n")
|
||||
.map(|pos| search_start + pos + 2)
|
||||
.or_else(|| window[..search_start].rfind("\n\n").map(|pos| pos + 2))
|
||||
}
|
||||
|
||||
fn find_sentence_break(window: &str) -> Option<usize> {
|
||||
let search_start = floor_char_boundary(window, window.len() / 2);
|
||||
for pat in &[". ", "? ", "! "] {
|
||||
if let Some(pos) = window[search_start..].rfind(pat) {
|
||||
return Some(search_start + pos + pat.len());
|
||||
}
|
||||
}
|
||||
for pat in &[". ", "? ", "! "] {
|
||||
if let Some(pos) = window[..search_start].rfind(pat) {
|
||||
return Some(pos + pat.len());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_word_break(window: &str) -> Option<usize> {
|
||||
let search_start = floor_char_boundary(window, window.len() / 2);
|
||||
window[search_start..]
|
||||
.rfind(' ')
|
||||
.map(|pos| search_start + pos + 1)
|
||||
.or_else(|| window[..search_start].rfind(' ').map(|pos| pos + 1))
|
||||
}
|
||||
|
||||
fn floor_char_boundary(s: &str, idx: usize) -> usize {
|
||||
if idx >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = idx;
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chunking_tests.rs"]
|
||||
mod tests;
|
||||
@@ -53,14 +53,8 @@ pub struct NormalizedNote {
|
||||
pub position_head_sha: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_timestamp(ts: &str) -> i64 {
|
||||
match iso_to_ms(ts) {
|
||||
Some(ms) => ms,
|
||||
None => {
|
||||
warn!(timestamp = ts, "Invalid timestamp, defaulting to epoch 0");
|
||||
0
|
||||
}
|
||||
}
|
||||
fn parse_timestamp(ts: &str) -> Result<i64, String> {
|
||||
iso_to_ms_strict(ts)
|
||||
}
|
||||
|
||||
pub fn transform_discussion(
|
||||
@@ -133,7 +127,15 @@ pub fn transform_notes(
|
||||
.notes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, note)| transform_single_note(note, local_project_id, idx as i32, now))
|
||||
.filter_map(|(idx, note)| {
|
||||
match transform_single_note(note, local_project_id, idx as i32, now) {
|
||||
Ok(n) => Some(n),
|
||||
Err(e) => {
|
||||
warn!(note_id = note.id, error = %e, "Skipping note with invalid timestamp");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -142,7 +144,10 @@ fn transform_single_note(
|
||||
local_project_id: i64,
|
||||
position: i32,
|
||||
now: i64,
|
||||
) -> NormalizedNote {
|
||||
) -> Result<NormalizedNote, String> {
|
||||
let created_at = parse_timestamp(¬e.created_at)?;
|
||||
let updated_at = parse_timestamp(¬e.updated_at)?;
|
||||
|
||||
let (
|
||||
position_old_path,
|
||||
position_new_path,
|
||||
@@ -156,7 +161,7 @@ fn transform_single_note(
|
||||
position_head_sha,
|
||||
) = extract_position_fields(¬e.position);
|
||||
|
||||
NormalizedNote {
|
||||
Ok(NormalizedNote {
|
||||
gitlab_id: note.id,
|
||||
project_id: local_project_id,
|
||||
note_type: note.note_type.clone(),
|
||||
@@ -164,8 +169,8 @@ fn transform_single_note(
|
||||
author_id: Some(note.author.id),
|
||||
author_username: note.author.username.clone(),
|
||||
body: note.body.clone(),
|
||||
created_at: parse_timestamp(¬e.created_at),
|
||||
updated_at: parse_timestamp(¬e.updated_at),
|
||||
created_at,
|
||||
updated_at,
|
||||
last_seen_at: now,
|
||||
position,
|
||||
resolvable: note.resolvable,
|
||||
@@ -182,7 +187,7 @@ fn transform_single_note(
|
||||
position_base_sha,
|
||||
position_start_sha,
|
||||
position_head_sha,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
|
||||
@@ -40,7 +40,11 @@ fn setup() -> Connection {
|
||||
}
|
||||
|
||||
fn get_discussion_id(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0))
|
||||
conn.query_row(
|
||||
"SELECT id FROM discussions ORDER BY id LIMIT 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -786,7 +786,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn get_mr_discussion_id(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0))
|
||||
conn.query_row(
|
||||
"SELECT id FROM discussions ORDER BY id LIMIT 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -242,14 +242,16 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let project_id: i64 = conn
|
||||
.query_row("SELECT id FROM projects LIMIT 1", [], |row| row.get(0))
|
||||
.query_row("SELECT id FROM projects ORDER BY id LIMIT 1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
enqueue_job(&conn, project_id, "issue", 42, 100, "resource_events", None).unwrap();
|
||||
|
||||
let job_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM pending_dependent_fetches LIMIT 1",
|
||||
"SELECT id FROM pending_dependent_fetches ORDER BY id LIMIT 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -301,7 +303,9 @@ mod tests {
|
||||
let (conn, _job_id) = setup_db_with_job();
|
||||
|
||||
let project_id: i64 = conn
|
||||
.query_row("SELECT id FROM projects LIMIT 1", [], |row| row.get(0))
|
||||
.query_row("SELECT id FROM projects ORDER BY id LIMIT 1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let jobs = claim_jobs(&conn, "resource_events", project_id, 10).unwrap();
|
||||
assert_eq!(jobs.len(), 1);
|
||||
|
||||
81
src/main.rs
81
src/main.rs
@@ -13,23 +13,24 @@ use lore::cli::autocorrect::{self, CorrectionResult};
|
||||
use lore::cli::commands::{
|
||||
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
||||
NoteListFilters, RefreshOptions, RefreshResult, SearchCliFilters, SyncOptions, TimelineParams,
|
||||
delete_orphan_projects, open_issue_in_browser, open_mr_in_browser, parse_trace_path,
|
||||
print_count, print_count_json, print_cron_install, print_cron_install_json, print_cron_status,
|
||||
print_cron_status_json, print_cron_uninstall, print_cron_uninstall_json, print_doctor_results,
|
||||
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json,
|
||||
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history,
|
||||
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
|
||||
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
|
||||
print_list_mrs_json, print_list_notes, print_list_notes_json, print_related_human,
|
||||
print_related_json, print_search_results, print_search_results_json, print_show_issue,
|
||||
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
||||
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
||||
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
|
||||
query_notes, run_auth_test, run_count, run_count_events, run_cron_install, run_cron_status,
|
||||
run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history, run_generate_docs,
|
||||
run_ingest, run_ingest_dry_run, run_init, run_init_refresh, run_list_issues, run_list_mrs,
|
||||
run_me, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||
run_sync_status, run_timeline, run_token_set, run_token_show, run_who,
|
||||
delete_orphan_projects, handle_explain, open_issue_in_browser, open_mr_in_browser,
|
||||
parse_trace_path, print_count, print_count_json, print_cron_install, print_cron_install_json,
|
||||
print_cron_status, print_cron_status_json, print_cron_uninstall, print_cron_uninstall_json,
|
||||
print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview,
|
||||
print_dry_run_preview_json, print_embed, print_embed_json, print_event_count,
|
||||
print_event_count_json, print_file_history, print_file_history_json, print_generate_docs,
|
||||
print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues,
|
||||
print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes,
|
||||
print_list_notes_json, print_related_human, print_related_json, print_search_results,
|
||||
print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
|
||||
print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
|
||||
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
|
||||
print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test,
|
||||
run_count, run_count_events, run_cron_install, run_cron_status, run_cron_uninstall, run_doctor,
|
||||
run_drift, run_embed, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run,
|
||||
run_init, run_init_refresh, run_list_issues, run_list_mrs, run_me, run_related, run_search,
|
||||
run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_token_set,
|
||||
run_token_show, run_who,
|
||||
};
|
||||
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||
@@ -222,6 +223,25 @@ fn main() {
|
||||
Some(Commands::Trace(args)) => handle_trace(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::Cron(args)) => handle_cron(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::Token(args)) => handle_token(cli.config.as_deref(), args, robot_mode).await,
|
||||
Some(Commands::Explain {
|
||||
entity_type,
|
||||
iid,
|
||||
project,
|
||||
sections,
|
||||
no_timeline,
|
||||
max_decisions,
|
||||
since,
|
||||
}) => handle_explain(
|
||||
cli.config.as_deref(),
|
||||
&entity_type,
|
||||
iid,
|
||||
project.as_deref(),
|
||||
sections,
|
||||
no_timeline,
|
||||
max_decisions,
|
||||
since.as_deref(),
|
||||
robot_mode,
|
||||
),
|
||||
Some(Commands::Drift {
|
||||
entity_type,
|
||||
iid,
|
||||
@@ -365,33 +385,6 @@ fn main() {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(Commands::Show {
|
||||
entity,
|
||||
iid,
|
||||
project,
|
||||
}) => {
|
||||
if robot_mode {
|
||||
eprintln!(
|
||||
r#"{{"warning":{{"type":"DEPRECATED","message":"'lore show' is deprecated, use 'lore {entity}s {iid}'","successor":"{entity}s"}}}}"#
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::warning().render(&format!(
|
||||
"warning: 'lore show' is deprecated, use 'lore {}s {}'",
|
||||
entity, iid
|
||||
))
|
||||
);
|
||||
}
|
||||
handle_show_compat(
|
||||
cli.config.as_deref(),
|
||||
&entity,
|
||||
iid,
|
||||
project.as_deref(),
|
||||
robot_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(Commands::AuthTest) => {
|
||||
if robot_mode {
|
||||
eprintln!(
|
||||
|
||||
@@ -119,15 +119,12 @@ pub fn search_fts(
|
||||
}
|
||||
|
||||
pub fn generate_fallback_snippet(content_text: &str, max_chars: usize) -> String {
|
||||
if content_text.chars().count() <= max_chars {
|
||||
return content_text.to_string();
|
||||
}
|
||||
|
||||
let byte_end = content_text
|
||||
.char_indices()
|
||||
.nth(max_chars)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(content_text.len());
|
||||
// Use char_indices to find the boundary at max_chars in a single pass,
|
||||
// short-circuiting early for large strings instead of counting all chars.
|
||||
let byte_end = match content_text.char_indices().nth(max_chars) {
|
||||
Some((i, _)) => i,
|
||||
None => return content_text.to_string(), // content fits within max_chars
|
||||
};
|
||||
let truncated = &content_text[..byte_end];
|
||||
|
||||
if let Some(last_space) = truncated.rfind(' ') {
|
||||
|
||||
@@ -48,6 +48,21 @@ pub async fn seed_timeline(
|
||||
});
|
||||
}
|
||||
|
||||
// Guard: reject overly broad queries before running expensive ranked search.
|
||||
// The count query (no bm25/snippet) is cheap even on broad matches.
|
||||
const SEED_MATCH_CEILING: i64 = 10_000;
|
||||
let match_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH ?1",
|
||||
[&fts_query],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if match_count > SEED_MATCH_CEILING {
|
||||
return Err(crate::core::error::LoreError::Other(format!(
|
||||
"Query too broad: matched {match_count} documents (ceiling: {SEED_MATCH_CEILING}). \
|
||||
Use a more specific query or narrow with --since.",
|
||||
)));
|
||||
}
|
||||
|
||||
// Use hybrid search for seed entity discovery (better recall than FTS alone).
|
||||
// search_hybrid gracefully falls back to FTS-only when Ollama is unavailable.
|
||||
let filters = SearchFilters {
|
||||
@@ -396,7 +411,9 @@ fn round_robin_select_by_discussion(
|
||||
let mut made_progress = false;
|
||||
|
||||
for (disc_idx, &discussion_id) in discussion_order.iter().enumerate() {
|
||||
let notes = by_discussion.get(&discussion_id).unwrap();
|
||||
let notes = by_discussion
|
||||
.get(&discussion_id)
|
||||
.expect("key present: inserted into by_discussion via discussion_order");
|
||||
let note_idx = indices[disc_idx];
|
||||
|
||||
if note_idx < notes.len() {
|
||||
|
||||
Reference in New Issue
Block a user