Compare commits
71 Commits
06229ce98b
...
trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
171260a772 | ||
|
|
a1bca10408 | ||
|
|
491dc52864 | ||
|
|
b9063aa17a | ||
|
|
fc0d9cb1d3 | ||
|
|
c8b47bf8f8 | ||
|
|
a570327a6b | ||
|
|
eef73decb5 | ||
|
|
bb6660178c | ||
|
|
64e73b1cab | ||
|
|
361757568f | ||
|
|
8572f6cc04 | ||
|
|
d0744039ef | ||
|
|
4b372dfb38 | ||
|
|
af8fc4af76 | ||
|
|
96b288ccdd | ||
|
|
d710403567 | ||
|
|
ebf64816c9 | ||
|
|
450951dee1 | ||
|
|
81f049a7fa | ||
|
|
dd00a2b840 | ||
|
|
c6a5461d41 | ||
|
|
a7f86b26e4 | ||
|
|
5ee8b0841c | ||
|
|
7062a3f1fd | ||
|
|
159c490ad7 | ||
|
|
e0041ed4d9 | ||
|
|
a34751bd47 | ||
|
|
0aecbf33c0 | ||
|
|
c10471ddb9 | ||
|
|
cbce4c9f59 | ||
|
|
94435c37f0 | ||
|
|
59f65b127a | ||
|
|
f36e900570 | ||
|
|
e2efc61beb | ||
|
|
2da1a228b3 | ||
|
|
0e65202778 | ||
|
|
f439c42b3d | ||
|
|
4f3ec72923 | ||
|
|
e6771709f1 | ||
|
|
8c86b0dfd7 | ||
|
|
6e55b2470d | ||
|
|
b05922d60b | ||
|
|
11fe02fac9 | ||
|
|
48fbd4bfdb | ||
|
|
9786ef27f5 | ||
|
|
7e0e6a91f2 | ||
|
|
5c2df3df3b | ||
|
|
94c8613420 | ||
|
|
ad4dd6e855 | ||
|
|
83cd16c918 | ||
|
|
fda9cd8835 | ||
|
|
c8d609ab78 | ||
|
|
35c828ba73 | ||
|
|
ecbfef537a | ||
|
|
47eecce8e9 | ||
|
|
b29c382583 | ||
|
|
e26816333f | ||
|
|
f772de8aef | ||
|
|
dd4d867c6e | ||
|
|
ffd074499a | ||
|
|
125938fba6 | ||
|
|
cd25cf61ca | ||
|
|
d9c9f6e541 | ||
|
|
acc5e12e3d | ||
|
|
039ab1c2a3 | ||
|
|
d63d6f0b9c | ||
|
|
3a1307dcdc | ||
|
|
6ea3108a20 | ||
|
|
81647545e7 | ||
|
|
39a832688d |
232
.beads/.br_history/issues.20260212_161438.jsonl
Normal file
232
.beads/.br_history/issues.20260212_161438.jsonl
Normal file
File diff suppressed because one or more lines are too long
295
.beads/.br_history/issues.20260212_171003.jsonl
Normal file
295
.beads/.br_history/issues.20260212_171003.jsonl
Normal file
File diff suppressed because one or more lines are too long
304
.beads/.br_history/issues.20260212_171103.jsonl
Normal file
304
.beads/.br_history/issues.20260212_171103.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-3hjh
|
bd-1sc6
|
||||||
|
|||||||
99
.claude/plan.md
Normal file
99
.claude/plan.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Plan: Add Colors to Sync Command Output
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The sync output has three layers, each needing color treatment:
|
||||||
|
|
||||||
|
### Layer 1: Stage Lines (during sync)
|
||||||
|
```
|
||||||
|
✓ Issues 10 issues from 2 projects 4.2s
|
||||||
|
✓ Status 3 statuses updated · 5 seen 4.2s
|
||||||
|
vs/typescript-code 2 issues · 1 statuses updated
|
||||||
|
✓ MRs 5 merge requests from 2 projects 12.3s
|
||||||
|
vs/python-code 3 MRs · 10 discussions
|
||||||
|
✓ Docs 1,200 documents generated 8.1s
|
||||||
|
✓ Embed 3,400 chunks embedded 45.2s
|
||||||
|
```
|
||||||
|
|
||||||
|
**What's uncolored:** icons, labels, numbers, elapsed times, sub-row project paths, failure counts in parentheses.
|
||||||
|
|
||||||
|
### Layer 2: Summary (after sync)
|
||||||
|
```
|
||||||
|
Synced 10 issues and 5 MRs in 42.3s
|
||||||
|
120 discussions · 45 events · 12 diffs · 3 statuses updated
|
||||||
|
1,200 docs regenerated · 3,400 embedded
|
||||||
|
```
|
||||||
|
|
||||||
|
**What's already colored:** headline ("Synced" = green bold, "Sync completed with issues" = warning bold), issue/MR counts (bold), error line (red). Detail lines are all dim.
|
||||||
|
|
||||||
|
### Layer 3: Timing breakdown (`-t` flag)
|
||||||
|
```
|
||||||
|
── Timing ──────────────────────
|
||||||
|
issues .............. 4.2s
|
||||||
|
merge_requests ...... 12.3s
|
||||||
|
```
|
||||||
|
|
||||||
|
**What's already colored:** dots (dim), time (bold), errors (red), rate limits (warning).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Plan
|
||||||
|
|
||||||
|
Using only existing `Theme` methods — no new colors needed.
|
||||||
|
|
||||||
|
### Stage Lines (`format_stage_line` + callers in sync.rs)
|
||||||
|
|
||||||
|
| Element | Current | Proposed | Theme method |
|
||||||
|
|---------|---------|----------|-------------|
|
||||||
|
| Icon (✓/⚠) | plain | green for success, yellow for warning | `Theme::success()` / `Theme::warning()` |
|
||||||
|
| Label ("Issues", "MRs", etc.) | plain | bold | `Theme::bold()` |
|
||||||
|
| Numbers in summary text | plain | bold | `Theme::bold()` (just the count) |
|
||||||
|
| Elapsed time | plain | muted gray | `Theme::timing()` |
|
||||||
|
| Failure text in parens | plain | warning/error color | `Theme::warning()` |
|
||||||
|
|
||||||
|
### Sub-rows (project breakdown lines)
|
||||||
|
|
||||||
|
| Element | Current | Proposed |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| Project path | dim | `Theme::muted()` (slightly brighter than dim) |
|
||||||
|
| Counts (numbers only) | dim | `Theme::dim()` but numbers in normal weight |
|
||||||
|
| Error/failure counts | dim | `Theme::warning()` |
|
||||||
|
| Middle dots | dim | keep dim (they're separators, should recede) |
|
||||||
|
|
||||||
|
### Summary (`print_sync`)
|
||||||
|
|
||||||
|
| Element | Current | Proposed |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| Issue/MR counts in headline | bold only | `Theme::info()` + bold (cyan numbers pop) |
|
||||||
|
| Time in headline | plain | `Theme::timing()` |
|
||||||
|
| Detail line numbers | all dim | numbers in `Theme::info()`, rest stays dim |
|
||||||
|
| Doc line numbers | all dim | numbers in `Theme::info()`, rest stays dim |
|
||||||
|
| "Already up to date" time | plain | `Theme::timing()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Change
|
||||||
|
|
||||||
|
1. **`src/cli/progress.rs`** — `format_stage_line()`: apply color to icon, bold to label, `Theme::timing()` to elapsed
|
||||||
|
2. **`src/cli/commands/sync.rs`** —
|
||||||
|
- Pass colored icons to `format_stage_line` / `emit_stage_line` / `emit_stage_block`
|
||||||
|
- Color failure text in `append_failures()`
|
||||||
|
- Color numbers and time in `print_sync()`
|
||||||
|
- Color error/failure counts in sub-row functions (`issue_sub_rows`, `mr_sub_rows`, `status_sub_rows`)
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
- `format_stage_line` already receives the icon string — color it before passing
|
||||||
|
- Add a `color_icon` helper that applies success/warning color to the icon glyph
|
||||||
|
- Bold the label in `format_stage_line`
|
||||||
|
- Apply `Theme::timing()` to elapsed in `format_stage_line`
|
||||||
|
- In `append_failures`, wrap failure text in `Theme::warning()`
|
||||||
|
- In `print_sync`, wrap count numbers with `Theme::info().bold()`
|
||||||
|
- In sub-row functions, apply `Theme::warning()` to error/failure parts only (keep rest dim)
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to robot mode (JSON output)
|
||||||
|
- No changes to dry-run output (already reasonably colored)
|
||||||
|
- No new Theme colors — use existing palette
|
||||||
|
- No changes to timing breakdown (already colored)
|
||||||
21
.github/workflows/roam.yml
vendored
Normal file
21
.github/workflows/roam.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Roam Code Analysis
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
jobs:
|
||||||
|
roam:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- run: pip install roam-code
|
||||||
|
- run: roam index
|
||||||
|
- run: roam fitness
|
||||||
|
- run: roam pr-risk --json
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,6 +41,9 @@ lore.config.json
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
|
|
||||||
|
# Mock seed data
|
||||||
|
tools/mock-seed/
|
||||||
|
|
||||||
# Added by cargo
|
# Added by cargo
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
|||||||
11
.roam/fitness.yaml
Normal file
11
.roam/fitness.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
rules:
|
||||||
|
- name: No circular imports in core
|
||||||
|
type: dependency
|
||||||
|
source: "src/**"
|
||||||
|
forbidden_target: "tests/**"
|
||||||
|
reason: "Production code should not import test modules"
|
||||||
|
- name: Complexity threshold
|
||||||
|
type: metric
|
||||||
|
metric: cognitive_complexity
|
||||||
|
threshold: 30
|
||||||
|
reason: "Functions above 30 cognitive complexity need refactoring"
|
||||||
59
AGENTS.md
59
AGENTS.md
@@ -324,7 +324,7 @@ bv --robot-insights | jq '.Cycles' # Circular deps (must
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ubs file.rs file2.rs # Specific files (< 1s) — USE THIS
|
ubs file.rs file2.rs # Specific files (< 1s) — USE THIS
|
||||||
ubs $(git diff --name-only --cached) # Staged files — before commit
|
ubs $(jj diff --name-only) # Changed files — before commit
|
||||||
ubs --only=rust,toml src/ # Language filter (3-5x faster)
|
ubs --only=rust,toml src/ # Language filter (3-5x faster)
|
||||||
ubs --ci --fail-on-warning . # CI mode — before PR
|
ubs --ci --fail-on-warning . # CI mode — before PR
|
||||||
ubs . # Whole project (ignores target/, Cargo.lock)
|
ubs . # Whole project (ignores target/, Cargo.lock)
|
||||||
@@ -436,9 +436,9 @@ Returns structured results with file paths, line ranges, and extracted code snip
|
|||||||
|
|
||||||
## Beads Workflow Integration
|
## Beads Workflow Integration
|
||||||
|
|
||||||
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in git.
|
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in version control.
|
||||||
|
|
||||||
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
|
**Note:** `br` is non-invasive—it never executes VCS commands directly. You must commit manually after `br sync --flush-only`.
|
||||||
|
|
||||||
### Essential Commands
|
### Essential Commands
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ br create --title="..." --type=task --priority=2
|
|||||||
br update <id> --status=in_progress
|
br update <id> --status=in_progress
|
||||||
br close <id> --reason="Completed"
|
br close <id> --reason="Completed"
|
||||||
br close <id1> <id2> # Close multiple issues at once
|
br close <id1> <id2> # Close multiple issues at once
|
||||||
br sync --flush-only # Export to JSONL (then manually: git add .beads/ && git commit)
|
br sync --flush-only # Export to JSONL (then: jj commit -m "Update beads")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Workflow Pattern
|
### Workflow Pattern
|
||||||
@@ -474,15 +474,14 @@ br sync --flush-only # Export to JSONL (then manually: git add .beads/ && git c
|
|||||||
|
|
||||||
### Session Protocol
|
### Session Protocol
|
||||||
|
|
||||||
**Before ending any session, run this checklist:**
|
**Before ending any session, run this checklist (solo/lead only — workers skip VCS):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status # Check what changed
|
jj status # Check what changed
|
||||||
git add <files> # Stage code changes
|
br sync --flush-only # Export beads to JSONL
|
||||||
br sync --flush-only # Export beads to JSONL
|
jj commit -m "..." # Commit code and beads (jj auto-tracks all changes)
|
||||||
git add .beads/ # Stage beads changes
|
jj bookmark set <name> -r @- # Point bookmark at committed work
|
||||||
git commit -m "..." # Commit code and beads
|
jj git push -b <name> # Push to remote
|
||||||
git push # Push to remote
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Best Practices
|
### Best Practices
|
||||||
@@ -491,13 +490,15 @@ git push # Push to remote
|
|||||||
- Update status as you work (in_progress → closed)
|
- Update status as you work (in_progress → closed)
|
||||||
- Create new issues with `br create` when you discover tasks
|
- Create new issues with `br create` when you discover tasks
|
||||||
- Use descriptive titles and set appropriate priority/type
|
- Use descriptive titles and set appropriate priority/type
|
||||||
- Always run `br sync --flush-only` then commit .beads/ before ending session
|
- Always run `br sync --flush-only` then commit before ending session (jj auto-tracks .beads/)
|
||||||
|
|
||||||
<!-- end-bv-agent-instructions -->
|
<!-- end-bv-agent-instructions -->
|
||||||
|
|
||||||
## Landing the Plane (Session Completion)
|
## Landing the Plane (Session Completion)
|
||||||
|
|
||||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until push succeeds.
|
||||||
|
|
||||||
|
**WHO RUNS THIS:** Solo agents run it themselves. In multi-agent sessions, ONLY the team lead runs this. Workers skip VCS entirely.
|
||||||
|
|
||||||
**MANDATORY WORKFLOW:**
|
**MANDATORY WORKFLOW:**
|
||||||
|
|
||||||
@@ -506,19 +507,20 @@ git push # Push to remote
|
|||||||
3. **Update issue status** - Close finished work, update in-progress items
|
3. **Update issue status** - Close finished work, update in-progress items
|
||||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||||
```bash
|
```bash
|
||||||
git pull --rebase
|
jj git fetch # Get latest remote state
|
||||||
br sync --flush-only
|
jj rebase -d trunk() # Rebase onto latest trunk if needed
|
||||||
git add .beads/
|
br sync --flush-only # Export beads to JSONL
|
||||||
git commit -m "Update beads"
|
jj commit -m "Update beads" # Commit (jj auto-tracks .beads/ changes)
|
||||||
git push
|
jj bookmark set <name> -r @- # Point bookmark at committed work
|
||||||
git status # MUST show "up to date with origin"
|
jj git push -b <name> # Push to remote
|
||||||
|
jj log -r '<name>' # Verify bookmark position
|
||||||
```
|
```
|
||||||
5. **Clean up** - Clear stashes, prune remote branches
|
5. **Clean up** - Abandon empty orphan changes if any (`jj abandon <rev>`)
|
||||||
6. **Verify** - All changes committed AND pushed
|
6. **Verify** - All changes committed AND pushed
|
||||||
7. **Hand off** - Provide context for next session
|
7. **Hand off** - Provide context for next session
|
||||||
|
|
||||||
**CRITICAL RULES:**
|
**CRITICAL RULES:**
|
||||||
- Work is NOT complete until `git push` succeeds
|
- Work is NOT complete until `jj git push` succeeds
|
||||||
- NEVER stop before pushing - that leaves work stranded locally
|
- NEVER stop before pushing - that leaves work stranded locally
|
||||||
- NEVER say "ready to push when you are" - YOU must push
|
- NEVER say "ready to push when you are" - YOU must push
|
||||||
- If push fails, resolve and retry until it succeeds
|
- If push fails, resolve and retry until it succeeds
|
||||||
@@ -752,6 +754,21 @@ lore -J mrs --fields iid,title,state,draft,labels # Custom field list
|
|||||||
- Use `lore robot-docs` for response schema discovery
|
- Use `lore robot-docs` for response schema discovery
|
||||||
- The `-p` flag supports fuzzy project matching (suffix and substring)
|
- The `-p` flag supports fuzzy project matching (suffix and substring)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read/Write Split: lore vs glab
|
||||||
|
|
||||||
|
| Operation | Tool | Why |
|
||||||
|
|-----------|------|-----|
|
||||||
|
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
|
||||||
|
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
|
||||||
|
| Search across entities | lore | FTS5 + vector hybrid search |
|
||||||
|
| Expert/workload analysis | lore | who command — no glab equivalent |
|
||||||
|
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
|
||||||
|
| Create/update/close | glab | Write operations |
|
||||||
|
| Approve/merge MR | glab | Write operations |
|
||||||
|
| CI/CD pipelines | glab | Not in lore scope |
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
## UBS Quick Reference for AI Agents
|
## UBS Quick Reference for AI Agents
|
||||||
|
|
||||||
|
|||||||
742
AGENTS.md.backup
Normal file
742
AGENTS.md.backup
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## RULE 0 - THE FUNDAMENTAL OVERRIDE PEROGATIVE
|
||||||
|
|
||||||
|
If I tell you to do something, even if it goes against what follows below, YOU MUST LISTEN TO ME. I AM IN CHARGE, NOT YOU.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE NUMBER 1: NO FILE DELETION
|
||||||
|
|
||||||
|
**YOU ARE NEVER ALLOWED TO DELETE A FILE WITHOUT EXPRESS PERMISSION.** Even a new file that you yourself created, such as a test code file. You have a horrible track record of deleting critically important files or otherwise throwing away tons of expensive work. As a result, you have permanently lost any and all rights to determine that a file or folder should be deleted.
|
||||||
|
|
||||||
|
**YOU MUST ALWAYS ASK AND RECEIVE CLEAR, WRITTEN PERMISSION BEFORE EVER DELETING A FILE OR FOLDER OF ANY KIND.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Irreversible Git & Filesystem Actions — DO NOT EVER BREAK GLASS
|
||||||
|
|
||||||
|
> **Note:** Treat destructive commands as break-glass. If there's any doubt, stop and ask.
|
||||||
|
|
||||||
|
1. **Absolutely forbidden commands:** `git reset --hard`, `git clean -fd`, `rm -rf`, or any command that can delete or overwrite code/data must never be run unless the user explicitly provides the exact command and states, in the same message, that they understand and want the irreversible consequences.
|
||||||
|
2. **No guessing:** If there is any uncertainty about what a command might delete or overwrite, stop immediately and ask the user for specific approval. "I think it's safe" is never acceptable.
|
||||||
|
3. **Safer alternatives first:** When cleanup or rollbacks are needed, request permission to use non-destructive options (`git status`, `git diff`, `git stash`, copying to backups) before ever considering a destructive command.
|
||||||
|
4. **Mandatory explicit plan:** Even after explicit user authorization, restate the command verbatim, list exactly what will be affected, and wait for a confirmation that your understanding is correct. Only then may you execute it—if anything remains ambiguous, refuse and escalate.
|
||||||
|
5. **Document the confirmation:** When running any approved destructive command, record (in the session notes / final response) the exact user text that authorized it, the command actually run, and the execution time. If that record is absent, the operation did not happen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toolchain: Rust & Cargo
|
||||||
|
|
||||||
|
We only use **Cargo** in this project, NEVER any other package manager.
|
||||||
|
|
||||||
|
- **Edition/toolchain:** Follow `rust-toolchain.toml` (if present). Do not assume stable vs nightly.
|
||||||
|
- **Dependencies:** Explicit versions for stability; keep the set minimal.
|
||||||
|
- **Configuration:** Cargo.toml only
|
||||||
|
- **Unsafe code:** Forbidden (`#![forbid(unsafe_code)]`)
|
||||||
|
|
||||||
|
When writing Rust code, reference RUST_CLI_TOOLS_BEST_PRACTICES.md
|
||||||
|
|
||||||
|
### Release Profile
|
||||||
|
|
||||||
|
Use the release profile defined in `Cargo.toml`. If you need to change it, justify the
|
||||||
|
performance/size tradeoff and how it impacts determinism and cancellation behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Editing Discipline
|
||||||
|
|
||||||
|
### No Script-Based Changes
|
||||||
|
|
||||||
|
**NEVER** run a script that processes/changes code files in this repo. Brittle regex-based transformations create far more problems than they solve.
|
||||||
|
|
||||||
|
- **Always make code changes manually**, even when there are many instances
|
||||||
|
- For many simple changes: use parallel subagents
|
||||||
|
- For subtle/complex changes: do them methodically yourself
|
||||||
|
|
||||||
|
### No File Proliferation
|
||||||
|
|
||||||
|
If you want to change something or add a feature, **revise existing code files in place**.
|
||||||
|
|
||||||
|
**NEVER** create variations like:
|
||||||
|
- `mainV2.rs`
|
||||||
|
- `main_improved.rs`
|
||||||
|
- `main_enhanced.rs`
|
||||||
|
|
||||||
|
New files are reserved for **genuinely new functionality** that makes zero sense to include in any existing file. The bar for creating new files is **incredibly high**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
|
||||||
|
We do not care about backwards compatibility—we're in early development with no users. We want to do things the **RIGHT** way with **NO TECH DEBT**.
|
||||||
|
|
||||||
|
- Never create "compatibility shims"
|
||||||
|
- Never create wrapper functions for deprecated APIs
|
||||||
|
- Just fix the code directly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compiler Checks (CRITICAL)
|
||||||
|
|
||||||
|
**After any substantive code changes, you MUST verify no errors were introduced:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for compiler errors and warnings
|
||||||
|
cargo check --all-targets
|
||||||
|
|
||||||
|
# Check for clippy lints (pedantic + nursery are enabled)
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# Verify formatting
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see errors, **carefully understand and resolve each issue**. Read sufficient context to fix them the RIGHT way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit & Property Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run with output
|
||||||
|
cargo test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
When adding or changing primitives, add tests that assert the core invariants:
|
||||||
|
|
||||||
|
- no task leaks
|
||||||
|
- no obligation leaks
|
||||||
|
- losers are drained after races
|
||||||
|
- region close implies quiescence
|
||||||
|
|
||||||
|
Prefer deterministic lab-runtime tests for concurrency-sensitive behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Agent Mail — Multi-Agent Coordination
|
||||||
|
|
||||||
|
A mail-like layer that lets coding agents coordinate asynchronously via MCP tools and resources. Provides identities, inbox/outbox, searchable threads, and advisory file reservations with human-auditable artifacts in Git.
|
||||||
|
|
||||||
|
### Why It's Useful
|
||||||
|
|
||||||
|
- **Prevents conflicts:** Explicit file reservations (leases) for files/globs
|
||||||
|
- **Token-efficient:** Messages stored in per-project archive, not in context
|
||||||
|
- **Quick reads:** `resource://inbox/...`, `resource://thread/...`
|
||||||
|
|
||||||
|
### Same Repository Workflow
|
||||||
|
|
||||||
|
1. **Register identity:**
|
||||||
|
```
|
||||||
|
ensure_project(project_key=<abs-path>)
|
||||||
|
register_agent(project_key, program, model)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Reserve files before editing:**
|
||||||
|
```
|
||||||
|
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Communicate with threads:**
|
||||||
|
```
|
||||||
|
send_message(..., thread_id="FEAT-123")
|
||||||
|
fetch_inbox(project_key, agent_name)
|
||||||
|
acknowledge_message(project_key, agent_name, message_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Quick reads:**
|
||||||
|
```
|
||||||
|
resource://inbox/{Agent}?project=<abs-path>&limit=20
|
||||||
|
resource://thread/{id}?project=<abs-path>&include_bodies=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Macros vs Granular Tools
|
||||||
|
|
||||||
|
- **Prefer macros for speed:** `macro_start_session`, `macro_prepare_thread`, `macro_file_reservation_cycle`, `macro_contact_handshake`
|
||||||
|
- **Use granular tools for control:** `register_agent`, `file_reservation_paths`, `send_message`, `fetch_inbox`, `acknowledge_message`
|
||||||
|
|
||||||
|
### Common Pitfalls
|
||||||
|
|
||||||
|
- `"from_agent not registered"`: Always `register_agent` in the correct `project_key` first
|
||||||
|
- `"FILE_RESERVATION_CONFLICT"`: Adjust patterns, wait for expiry, or use non-exclusive reservation
|
||||||
|
- **Auth errors:** If JWT+JWKS enabled, include bearer token with matching `kid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beads (br) — Dependency-Aware Issue Tracking
|
||||||
|
|
||||||
|
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements MCP Agent Mail's messaging and file reservations.
|
||||||
|
|
||||||
|
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- **Single source of truth:** Beads for task status/priority/dependencies; Agent Mail for conversation and audit
|
||||||
|
- **Shared identifiers:** Use Beads issue ID (e.g., `br-123`) as Mail `thread_id` and prefix subjects with `[br-123]`
|
||||||
|
- **Reservations:** When starting a task, call `file_reservation_paths()` with the issue ID in `reason`
|
||||||
|
|
||||||
|
### Typical Agent Flow
|
||||||
|
|
||||||
|
1. **Pick ready work (Beads):**
|
||||||
|
```bash
|
||||||
|
br ready --json # Choose highest priority, no blockers
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Reserve edit surface (Mail):**
|
||||||
|
```
|
||||||
|
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true, reason="br-123")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Announce start (Mail):**
|
||||||
|
```
|
||||||
|
send_message(..., thread_id="br-123", subject="[br-123] Start: <title>", ack_required=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Work and update:** Reply in-thread with progress
|
||||||
|
|
||||||
|
5. **Complete and release:**
|
||||||
|
```bash
|
||||||
|
br close br-123 --reason "Completed"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
release_file_reservations(project_key, agent_name, paths=["src/**"])
|
||||||
|
```
|
||||||
|
Final Mail reply: `[br-123] Completed` with summary
|
||||||
|
|
||||||
|
### Mapping Cheat Sheet
|
||||||
|
|
||||||
|
| Concept | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Mail `thread_id` | `br-###` |
|
||||||
|
| Mail subject | `[br-###] ...` |
|
||||||
|
| File reservation `reason` | `br-###` |
|
||||||
|
| Commit messages | Include `br-###` for traceability |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bv — Graph-Aware Triage Engine
|
||||||
|
|
||||||
|
bv is a graph-aware triage engine for Beads projects (`.beads/beads.jsonl`). It computes PageRank, betweenness, critical path, cycles, HITS, eigenvector, and k-core metrics deterministically.
|
||||||
|
|
||||||
|
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (messaging, work claiming, file reservations), use MCP Agent Mail.
|
||||||
|
|
||||||
|
**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**
|
||||||
|
|
||||||
|
### The Workflow: Start With Triage
|
||||||
|
|
||||||
|
**`bv --robot-triage` is your single entry point.** It returns:
|
||||||
|
- `quick_ref`: at-a-glance counts + top 3 picks
|
||||||
|
- `recommendations`: ranked actionable items with scores, reasons, unblock info
|
||||||
|
- `quick_wins`: low-effort high-impact items
|
||||||
|
- `blockers_to_clear`: items that unblock the most downstream work
|
||||||
|
- `project_health`: status/type/priority distributions, graph metrics
|
||||||
|
- `commands`: copy-paste shell commands for next steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bv --robot-triage # THE MEGA-COMMAND: start here
|
||||||
|
bv --robot-next # Minimal: just the single top pick + claim command
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Reference
|
||||||
|
|
||||||
|
**Planning:**
|
||||||
|
| Command | Returns |
|
||||||
|
|---------|---------|
|
||||||
|
| `--robot-plan` | Parallel execution tracks with `unblocks` lists |
|
||||||
|
| `--robot-priority` | Priority misalignment detection with confidence |
|
||||||
|
|
||||||
|
**Graph Analysis:**
|
||||||
|
| Command | Returns |
|
||||||
|
|---------|---------|
|
||||||
|
| `--robot-insights` | Full metrics: PageRank, betweenness, HITS, eigenvector, critical path, cycles, k-core, articulation points, slack |
|
||||||
|
| `--robot-label-health` | Per-label health: `health_level`, `velocity_score`, `staleness`, `blocked_count` |
|
||||||
|
| `--robot-label-flow` | Cross-label dependency: `flow_matrix`, `dependencies`, `bottleneck_labels` |
|
||||||
|
| `--robot-label-attention [--attention-limit=N]` | Attention-ranked labels |
|
||||||
|
|
||||||
|
**History & Change Tracking:**
|
||||||
|
| Command | Returns |
|
||||||
|
|---------|---------|
|
||||||
|
| `--robot-history` | Bead-to-commit correlations |
|
||||||
|
| `--robot-diff --diff-since <ref>` | Changes since ref: new/closed/modified issues, cycles |
|
||||||
|
|
||||||
|
**Other:**
|
||||||
|
| Command | Returns |
|
||||||
|
|---------|---------|
|
||||||
|
| `--robot-burndown <sprint>` | Sprint burndown, scope changes, at-risk items |
|
||||||
|
| `--robot-forecast <id\|all>` | ETA predictions with dependency-aware scheduling |
|
||||||
|
| `--robot-alerts` | Stale issues, blocking cascades, priority mismatches |
|
||||||
|
| `--robot-suggest` | Hygiene: duplicates, missing deps, label suggestions |
|
||||||
|
| `--robot-graph [--graph-format=json\|dot\|mermaid]` | Dependency graph export |
|
||||||
|
| `--export-graph <file.html>` | Interactive HTML visualization |
|
||||||
|
|
||||||
|
### Scoping & Filtering
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bv --robot-plan --label backend # Scope to label's subgraph
|
||||||
|
bv --robot-insights --as-of HEAD~30 # Historical point-in-time
|
||||||
|
bv --recipe actionable --robot-plan # Pre-filter: ready to work
|
||||||
|
bv --recipe high-impact --robot-triage # Pre-filter: top PageRank
|
||||||
|
bv --robot-triage --robot-triage-by-track # Group by parallel work streams
|
||||||
|
bv --robot-triage --robot-triage-by-label # Group by domain
|
||||||
|
```
|
||||||
|
|
||||||
|
### Understanding Robot Output
|
||||||
|
|
||||||
|
**All robot JSON includes:**
|
||||||
|
- `data_hash` — Fingerprint of source beads.jsonl
|
||||||
|
- `status` — Per-metric state: `computed|approx|timeout|skipped` + elapsed ms
|
||||||
|
- `as_of` / `as_of_commit` — Present when using `--as-of`
|
||||||
|
|
||||||
|
**Two-phase analysis:**
|
||||||
|
- **Phase 1 (instant):** degree, topo sort, density
|
||||||
|
- **Phase 2 (async, 500ms timeout):** PageRank, betweenness, HITS, eigenvector, cycles
|
||||||
|
|
||||||
|
### jq Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bv --robot-triage | jq '.quick_ref' # At-a-glance summary
|
||||||
|
bv --robot-triage | jq '.recommendations[0]' # Top recommendation
|
||||||
|
bv --robot-plan | jq '.plan.summary.highest_impact' # Best unblock target
|
||||||
|
bv --robot-insights | jq '.status' # Check metric readiness
|
||||||
|
bv --robot-insights | jq '.Cycles' # Circular deps (must fix!)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UBS — Ultimate Bug Scanner
|
||||||
|
|
||||||
|
**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ubs file.rs file2.rs # Specific files (< 1s) — USE THIS
|
||||||
|
ubs $(git diff --name-only --cached) # Staged files — before commit
|
||||||
|
ubs --only=rust,toml src/ # Language filter (3-5x faster)
|
||||||
|
ubs --ci --fail-on-warning . # CI mode — before PR
|
||||||
|
ubs . # Whole project (ignores target/, Cargo.lock)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ Category (N errors)
|
||||||
|
file.rs:42:5 – Issue description
|
||||||
|
💡 Suggested fix
|
||||||
|
Exit code: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fail
|
||||||
|
|
||||||
|
### Fix Workflow
|
||||||
|
|
||||||
|
1. Read finding → category + fix suggestion
|
||||||
|
2. Navigate `file:line:col` → view context
|
||||||
|
3. Verify real issue (not false positive)
|
||||||
|
4. Fix root cause (not symptom)
|
||||||
|
5. Re-run `ubs <file>` → exit 0
|
||||||
|
6. Commit
|
||||||
|
|
||||||
|
### Bug Severity
|
||||||
|
|
||||||
|
- **Critical (always fix):** Memory safety, use-after-free, data races, SQL injection
|
||||||
|
- **Important (production):** Unwrap panics, resource leaks, overflow checks
|
||||||
|
- **Contextual (judgment):** TODO/FIXME, println! debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ast-grep vs ripgrep
|
||||||
|
|
||||||
|
**Use `ast-grep` when structure matters.** It parses code and matches AST nodes, ignoring comments/strings, and can **safely rewrite** code.
|
||||||
|
|
||||||
|
- Refactors/codemods: rename APIs, change import forms
|
||||||
|
- Policy checks: enforce patterns across a repo
|
||||||
|
- Editor/automation: LSP mode, `--json` output
|
||||||
|
|
||||||
|
**Use `ripgrep` when text is enough.** Fastest way to grep literals/regex.
|
||||||
|
|
||||||
|
- Recon: find strings, TODOs, log lines, config values
|
||||||
|
- Pre-filter: narrow candidate files before ast-grep
|
||||||
|
|
||||||
|
### Rule of Thumb
|
||||||
|
|
||||||
|
- Need correctness or **applying changes** → `ast-grep`
|
||||||
|
- Need raw speed or **hunting text** → `rg`
|
||||||
|
- Often combine: `rg` to shortlist files, then `ast-grep` to match/modify
|
||||||
|
|
||||||
|
### Rust Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find structured code (ignores comments)
|
||||||
|
ast-grep run -l Rust -p 'fn $NAME($$$ARGS) -> $RET { $$$BODY }'
|
||||||
|
|
||||||
|
# Find all unwrap() calls
|
||||||
|
ast-grep run -l Rust -p '$EXPR.unwrap()'
|
||||||
|
|
||||||
|
# Quick textual hunt
|
||||||
|
rg -n 'println!' -t rust
|
||||||
|
|
||||||
|
# Combine speed + precision
|
||||||
|
rg -l -t rust 'unwrap\(' | xargs ast-grep run -l Rust -p '$X.unwrap()' --json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Morph Warp Grep — AI-Powered Code Search
|
||||||
|
|
||||||
|
**Use `mcp__morph-mcp__warp_grep` for exploratory "how does X work?" questions.** An AI agent expands your query, greps the codebase, reads relevant files, and returns precise line ranges with full context.
|
||||||
|
|
||||||
|
**Use `ripgrep` for targeted searches.** When you know exactly what you're looking for.
|
||||||
|
|
||||||
|
**Use `ast-grep` for structural patterns.** When you need AST precision for matching/rewriting.
|
||||||
|
|
||||||
|
### When to Use What
|
||||||
|
|
||||||
|
| Scenario | Tool | Why |
|
||||||
|
|----------|------|-----|
|
||||||
|
| "How is pattern matching implemented?" | `warp_grep` | Exploratory; don't know where to start |
|
||||||
|
| "Where is the quick reject filter?" | `warp_grep` | Need to understand architecture |
|
||||||
|
| "Find all uses of `Regex::new`" | `ripgrep` | Targeted literal search |
|
||||||
|
| "Find files with `println!`" | `ripgrep` | Simple pattern |
|
||||||
|
| "Replace all `unwrap()` with `expect()`" | `ast-grep` | Structural refactor |
|
||||||
|
|
||||||
|
### warp_grep Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__morph-mcp__warp_grep(
|
||||||
|
repoPath: "/path/to/dcg",
|
||||||
|
query: "How does the safe pattern whitelist work?"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns structured results with file paths, line ranges, and extracted code snippets.
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
|
||||||
|
- **Don't** use `warp_grep` to find a specific function name → use `ripgrep`
|
||||||
|
- **Don't** use `ripgrep` to understand "how does X work" → wastes time with manual reads
|
||||||
|
- **Don't** use `ripgrep` for codemods → risks collateral edits
|
||||||
|
|
||||||
|
<!-- bv-agent-instructions-v1 -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beads Workflow Integration
|
||||||
|
|
||||||
|
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in git.
|
||||||
|
|
||||||
|
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View issues (launches TUI - avoid in automated sessions)
|
||||||
|
bv
|
||||||
|
|
||||||
|
# CLI commands for agents (use these instead)
|
||||||
|
br ready # Show issues ready to work (no blockers)
|
||||||
|
br list --status=open # All open issues
|
||||||
|
br show <id> # Full issue details with dependencies
|
||||||
|
br create --title="..." --type=task --priority=2
|
||||||
|
br update <id> --status=in_progress
|
||||||
|
br close <id> --reason="Completed"
|
||||||
|
br close <id1> <id2> # Close multiple issues at once
|
||||||
|
br sync --flush-only # Export to JSONL (then manually: git add .beads/ && git commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow Pattern
|
||||||
|
|
||||||
|
1. **Start**: Run `br ready` to find actionable work
|
||||||
|
2. **Claim**: Use `br update <id> --status=in_progress`
|
||||||
|
3. **Work**: Implement the task
|
||||||
|
4. **Complete**: Use `br close <id>`
|
||||||
|
5. **Sync**: Run `br sync --flush-only`, then `git add .beads/ && git commit -m "Update beads"`
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work.
|
||||||
|
- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words)
|
||||||
|
- **Types**: task, bug, feature, epic, question, docs
|
||||||
|
- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies
|
||||||
|
|
||||||
|
### Session Protocol
|
||||||
|
|
||||||
|
**Before ending any session, run this checklist:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status # Check what changed
|
||||||
|
git add <files> # Stage code changes
|
||||||
|
br sync --flush-only # Export beads to JSONL
|
||||||
|
git add .beads/ # Stage beads changes
|
||||||
|
git commit -m "..." # Commit code and beads
|
||||||
|
git push # Push to remote
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- Check `br ready` at session start to find available work
|
||||||
|
- Update status as you work (in_progress → closed)
|
||||||
|
- Create new issues with `br create` when you discover tasks
|
||||||
|
- Use descriptive titles and set appropriate priority/type
|
||||||
|
- Always run `br sync --flush-only` then commit .beads/ before ending session
|
||||||
|
|
||||||
|
<!-- end-bv-agent-instructions -->
|
||||||
|
|
||||||
|
## Landing the Plane (Session Completion)
|
||||||
|
|
||||||
|
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||||
|
|
||||||
|
**MANDATORY WORKFLOW:**
|
||||||
|
|
||||||
|
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||||
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||||
|
3. **Update issue status** - Close finished work, update in-progress items
|
||||||
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||||
|
```bash
|
||||||
|
git pull --rebase
|
||||||
|
br sync --flush-only
|
||||||
|
git add .beads/
|
||||||
|
git commit -m "Update beads"
|
||||||
|
git push
|
||||||
|
git status # MUST show "up to date with origin"
|
||||||
|
```
|
||||||
|
5. **Clean up** - Clear stashes, prune remote branches
|
||||||
|
6. **Verify** - All changes committed AND pushed
|
||||||
|
7. **Hand off** - Provide context for next session
|
||||||
|
|
||||||
|
**CRITICAL RULES:**
|
||||||
|
- Work is NOT complete until `git push` succeeds
|
||||||
|
- NEVER stop before pushing - that leaves work stranded locally
|
||||||
|
- NEVER say "ready to push when you are" - YOU must push
|
||||||
|
- If push fails, resolve and retry until it succeeds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cass — Cross-Agent Session Search
|
||||||
|
|
||||||
|
`cass` indexes prior agent conversations (Claude Code, Codex, Cursor, Gemini, ChatGPT, etc.) so we can reuse solved problems.
|
||||||
|
|
||||||
|
**Rules:** Never run bare `cass` (TUI). Always use `--robot` or `--json`.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cass health
|
||||||
|
cass search "async runtime" --robot --limit 5
|
||||||
|
cass view /path/to/session.jsonl -n 42 --json
|
||||||
|
cass expand /path/to/session.jsonl -n 42 -C 3 --json
|
||||||
|
cass capabilities --json
|
||||||
|
cass robot-docs guide
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Use `--fields minimal` for lean output
|
||||||
|
- Filter by agent with `--agent`
|
||||||
|
- Use `--days N` to limit to recent history
|
||||||
|
|
||||||
|
stdout is data-only, stderr is diagnostics; exit code 0 means success.
|
||||||
|
|
||||||
|
Treat cass as a way to avoid re-solving problems other agents already handled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note for Codex/GPT-5.2
|
||||||
|
|
||||||
|
You constantly bother me and stop working with concerned questions that look similar to this:
|
||||||
|
|
||||||
|
```
|
||||||
|
Unexpected changes (need guidance)
|
||||||
|
|
||||||
|
- Working tree still shows edits I did not make in Cargo.toml, Cargo.lock, src/runtime.rs, src/scope.rs. Please advise whether to keep/commit/revert these before any further work. I did not touch them.
|
||||||
|
|
||||||
|
Next steps (pick one)
|
||||||
|
|
||||||
|
1. Decide how to handle the unrelated modified files above so we can resume cleanly.
|
||||||
|
```
|
||||||
|
|
||||||
|
NEVER EVER DO THAT AGAIN. The answer is literally ALWAYS the same: those are changes created by the potentially dozen of other agents working on the project at the same time. This is not only a common occurence, it happens multiple times PER MINUTE. The way to deal with it is simple: you NEVER, under ANY CIRCUMSTANCE, stash, revert, overwrite, or otherwise disturb in ANY way the work of other agents. Just treat those changes identically to changes that you yourself made. Just fool yourself into thinking YOU made the changes and simply don't recall it for some reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note on Built-in TODO Functionality
|
||||||
|
|
||||||
|
Also, if I ask you to explicitly use your built-in TODO functionality, don't complain about this and say you need to use beads. You can use built-in TODOs if I tell you specifically to do so. Always comply with such orders.
|
||||||
|
|
||||||
|
## TDD Requirements
|
||||||
|
|
||||||
|
Test-first development is mandatory:
|
||||||
|
1. **RED** - Write failing test first
|
||||||
|
2. **GREEN** - Minimal implementation to pass
|
||||||
|
3. **REFACTOR** - Clean up while green
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
Find the simplest solution that meets all acceptance criteria.
|
||||||
|
Use third party libraries whenever there's a well-maintained, active, and widely adopted solution (for example, date-fns for TS date math)
|
||||||
|
Build extensible pieces of logic that can easily be integrated with other pieces.
|
||||||
|
DRY principles should be loosely held.
|
||||||
|
Architecture MUST be clear and well thought-out. Ask the user for clarification whenever ambiguity is discovered around architecture, or you think a better approach than planned exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Third-Party Library Usage
|
||||||
|
|
||||||
|
If you aren't 100% sure how to use a third-party library, **SEARCH ONLINE** to find the latest documentation and mid-2025 best practices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitlore Robot Mode
|
||||||
|
|
||||||
|
The `lore` CLI has a robot mode optimized for AI agent consumption with compact JSON output, structured errors with machine-actionable recovery steps, meaningful exit codes, response timing metadata, field selection for token efficiency, and TTY auto-detection.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Explicit flag
|
||||||
|
lore --robot issues -n 10
|
||||||
|
|
||||||
|
# JSON shorthand (-J)
|
||||||
|
lore -J issues -n 10
|
||||||
|
|
||||||
|
# Auto-detection (when stdout is not a TTY)
|
||||||
|
lore issues | jq .
|
||||||
|
|
||||||
|
# Environment variable
|
||||||
|
LORE_ROBOT=1 lore issues
|
||||||
|
```
|
||||||
|
|
||||||
|
### Robot Mode Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List issues/MRs with JSON output
|
||||||
|
lore --robot issues -n 10
|
||||||
|
lore --robot mrs -s opened
|
||||||
|
|
||||||
|
# List with field selection (reduces token usage ~60%)
|
||||||
|
lore --robot issues --fields minimal
|
||||||
|
lore --robot mrs --fields iid,title,state,draft
|
||||||
|
|
||||||
|
# Show detailed entity info
|
||||||
|
lore --robot issues 123
|
||||||
|
lore --robot mrs 456 -p group/repo
|
||||||
|
|
||||||
|
# Count entities
|
||||||
|
lore --robot count issues
|
||||||
|
lore --robot count discussions --for mr
|
||||||
|
|
||||||
|
# Search indexed documents
|
||||||
|
lore --robot search "authentication bug"
|
||||||
|
|
||||||
|
# Check sync status
|
||||||
|
lore --robot status
|
||||||
|
|
||||||
|
# Run full sync pipeline
|
||||||
|
lore --robot sync
|
||||||
|
|
||||||
|
# Run sync without resource events
|
||||||
|
lore --robot sync --no-events
|
||||||
|
|
||||||
|
# Run ingestion only
|
||||||
|
lore --robot ingest issues
|
||||||
|
|
||||||
|
# Check environment health
|
||||||
|
lore --robot doctor
|
||||||
|
|
||||||
|
# Document and index statistics
|
||||||
|
lore --robot stats
|
||||||
|
|
||||||
|
# Quick health pre-flight check (exit 0 = healthy, 19 = unhealthy)
|
||||||
|
lore --robot health
|
||||||
|
|
||||||
|
# Generate searchable documents from ingested data
|
||||||
|
lore --robot generate-docs
|
||||||
|
|
||||||
|
# Generate vector embeddings via Ollama
|
||||||
|
lore --robot embed
|
||||||
|
|
||||||
|
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||||
|
lore robot-docs
|
||||||
|
|
||||||
|
# Version information
|
||||||
|
lore --robot version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
All commands return compact JSON with a uniform envelope and timing metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":true,"data":{...},"meta":{"elapsed_ms":42}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors return structured JSON to stderr with machine-actionable recovery steps:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error":{"code":"CONFIG_NOT_FOUND","message":"...","suggestion":"Run 'lore init'","actions":["lore init"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `actions` array contains executable shell commands for automated recovery. It is omitted when empty.
|
||||||
|
|
||||||
|
### Field Selection
|
||||||
|
|
||||||
|
The `--fields` flag on `issues` and `mrs` list commands controls which fields appear in the JSON response:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore -J issues --fields minimal # Preset: iid, title, state, updated_at_iso
|
||||||
|
lore -J mrs --fields iid,title,state,draft,labels # Custom field list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | Success |
|
||||||
|
| 1 | Internal error / not implemented |
|
||||||
|
| 2 | Usage error (invalid flags or arguments) |
|
||||||
|
| 3 | Config invalid |
|
||||||
|
| 4 | Token not set |
|
||||||
|
| 5 | GitLab auth failed |
|
||||||
|
| 6 | Resource not found |
|
||||||
|
| 7 | Rate limited |
|
||||||
|
| 8 | Network error |
|
||||||
|
| 9 | Database locked |
|
||||||
|
| 10 | Database error |
|
||||||
|
| 11 | Migration failed |
|
||||||
|
| 12 | I/O error |
|
||||||
|
| 13 | Transform error |
|
||||||
|
| 14 | Ollama unavailable |
|
||||||
|
| 15 | Ollama model not found |
|
||||||
|
| 16 | Embedding failed |
|
||||||
|
| 17 | Not found (entity does not exist) |
|
||||||
|
| 18 | Ambiguous match (use `-p` to specify project) |
|
||||||
|
| 19 | Health check failed |
|
||||||
|
| 20 | Config not found |
|
||||||
|
|
||||||
|
### Configuration Precedence
|
||||||
|
|
||||||
|
1. CLI flags (highest priority)
|
||||||
|
2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`)
|
||||||
|
3. Config file (`~/.config/lore/config.json`)
|
||||||
|
4. Built-in defaults (lowest priority)
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- Use `lore --robot` or `lore -J` for all agent interactions
|
||||||
|
- Check exit codes for error handling
|
||||||
|
- Parse JSON errors from stderr; use `actions` array for automated recovery
|
||||||
|
- Use `--fields minimal` to reduce token usage (~60% fewer tokens)
|
||||||
|
- Use `-n` / `--limit` to control response size
|
||||||
|
- Use `-q` / `--quiet` to suppress progress bars and non-essential output
|
||||||
|
- Use `--color never` in non-TTY automation for ANSI-free output
|
||||||
|
- Use `-v` / `-vv` / `-vvv` for increasing verbosity (debug/trace logging)
|
||||||
|
- Use `--log-format json` for machine-readable log output to stderr
|
||||||
|
- TTY detection handles piped commands automatically
|
||||||
|
- Use `lore --robot health` as a fast pre-flight check before queries
|
||||||
|
- Use `lore robot-docs` for response schema discovery
|
||||||
|
- The `-p` flag supports fuzzy project matching (suffix and substring)
|
||||||
173
Cargo.lock
generated
173
Cargo.lock
generated
@@ -169,6 +169,23 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charmed-lipgloss"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"colored",
|
||||||
|
"crossterm",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
@@ -239,14 +256,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comfy-table"
|
name = "colored"
|
||||||
version = "7.2.2"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"lazy_static",
|
||||||
"unicode-segmentation",
|
"windows-sys 0.52.0",
|
||||||
"unicode-width",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -258,10 +274,19 @@ dependencies = [
|
|||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -319,9 +344,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
|
"derive_more",
|
||||||
"document-features",
|
"document-features",
|
||||||
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -371,6 +400,28 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||||
|
dependencies = [
|
||||||
|
"derive_more-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more-impl"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dialoguer"
|
name = "dialoguer"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -976,7 +1027,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"console",
|
"console",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
"unit-prefix",
|
"unit-prefix",
|
||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
@@ -1106,13 +1157,13 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lore"
|
name = "lore"
|
||||||
version = "0.5.2"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"charmed-lipgloss",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"comfy-table",
|
|
||||||
"console",
|
"console",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -1181,6 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -1574,6 +1626,15 @@ dependencies = [
|
|||||||
"sqlite-wasm-rs",
|
"sqlite-wasm-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
@@ -1670,6 +1731,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -1713,6 +1780,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1757,6 +1833,27 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.5"
|
version = "1.4.5"
|
||||||
@@ -2028,6 +2125,47 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2183,6 +2321,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2611,6 +2755,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wiremock"
|
name = "wiremock"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lore"
|
name = "lore"
|
||||||
version = "0.5.2"
|
version = "0.8.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Gitlore - Local GitLab data management with semantic search"
|
description = "Gitlore - Local GitLab data management with semantic search"
|
||||||
authors = ["Taylor Eernisse"]
|
authors = ["Taylor Eernisse"]
|
||||||
@@ -25,7 +25,7 @@ clap_complete = "4"
|
|||||||
dialoguer = "0.12"
|
dialoguer = "0.12"
|
||||||
console = "0.16"
|
console = "0.16"
|
||||||
indicatif = "0.18"
|
indicatif = "0.18"
|
||||||
comfy-table = "7"
|
lipgloss = { package = "charmed-lipgloss", version = "0.1", default-features = false, features = ["native"] }
|
||||||
open = "5"
|
open = "5"
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
|
|||||||
425
PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md
Normal file
425
PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# Proposed Code File Reorganization Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The codebase is 79 Rust source files / 46K lines across 7 top-level modules. Most modules (`gitlab/`, `embedding/`, `search/`, `documents/`, `ingestion/`) are well-organized. The pain points are:
|
||||||
|
|
||||||
|
1. **`core/` is a grab-bag** — 22 files mixing infrastructure, domain logic, DB operations, and an entire timeline pipeline
|
||||||
|
2. **`main.rs` is 2713 lines** — ~30 handler functions that bridge CLI args to commands
|
||||||
|
3. **`cli/mod.rs` is 949 lines** — every clap argument struct is packed into one file
|
||||||
|
4. **Giant command files** — `who.rs` (6067 lines), `list.rs` (2931 lines) are unwieldy
|
||||||
|
|
||||||
|
This plan is organized into **three tiers** based on impact-to-risk ratio. Tier 1 changes are "no-brainers" — they reduce confusion with minimal import churn. Tier 2 changes are valuable but involve more cross-cutting import updates. Tier 3 changes are "maybe later" — they'd be nice but the juice might not be worth the squeeze right now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Structure (Annotated)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs (2713 lines) ← dispatch + ~30 handler functions + error helpers
|
||||||
|
├── lib.rs (9 lines)
|
||||||
|
├── cli/
|
||||||
|
│ ├── mod.rs (949 lines) ← ALL clap arg structs crammed here
|
||||||
|
│ ├── autocorrect.rs (945 lines)
|
||||||
|
│ ├── progress.rs (92 lines)
|
||||||
|
│ ├── robot.rs (111 lines)
|
||||||
|
│ └── commands/
|
||||||
|
│ ├── mod.rs (50 lines) — re-exports
|
||||||
|
│ ├── auth_test.rs
|
||||||
|
│ ├── count.rs (406 lines)
|
||||||
|
│ ├── doctor.rs (576 lines)
|
||||||
|
│ ├── drift.rs (642 lines)
|
||||||
|
│ ├── embed.rs
|
||||||
|
│ ├── generate_docs.rs (320 lines)
|
||||||
|
│ ├── ingest.rs (1064 lines)
|
||||||
|
│ ├── init.rs (174 lines)
|
||||||
|
│ ├── list.rs (2931 lines) ← handles issues, MRs, AND notes listing
|
||||||
|
│ ├── search.rs (418 lines)
|
||||||
|
│ ├── show.rs (1377 lines)
|
||||||
|
│ ├── stats.rs (505 lines)
|
||||||
|
│ ├── sync_status.rs (454 lines)
|
||||||
|
│ ├── sync.rs (576 lines)
|
||||||
|
│ ├── timeline.rs (488 lines)
|
||||||
|
│ └── who.rs (6067 lines) ← 5 sub-modes: expert, workload, active, overlap, reviews
|
||||||
|
├── core/
|
||||||
|
│ ├── mod.rs (25 lines)
|
||||||
|
│ ├── backoff.rs ← retry logic (used by ingestion)
|
||||||
|
│ ├── config.rs (789 lines) ← configuration types
|
||||||
|
│ ├── db.rs (970 lines) ← connection + 22 migrations
|
||||||
|
│ ├── dependent_queue.rs (330 lines) ← job queue (used by ingestion orchestrator)
|
||||||
|
│ ├── error.rs (295 lines) ← error enum + exit codes
|
||||||
|
│ ├── events_db.rs (199 lines) ← resource event upserts (used by ingestion)
|
||||||
|
│ ├── lock.rs (228 lines) ← filesystem sync lock
|
||||||
|
│ ├── logging.rs (179 lines) ← tracing filter builders
|
||||||
|
│ ├── metrics.rs (566 lines) ← tracing-based stage timing
|
||||||
|
│ ├── note_parser.rs (563 lines) ← cross-ref extraction from note bodies
|
||||||
|
│ ├── paths.rs ← config/db/log file path resolution
|
||||||
|
│ ├── payloads.rs (204 lines) ← raw JSON payload storage
|
||||||
|
│ ├── project.rs (274 lines) ← fuzzy project resolution from DB
|
||||||
|
│ ├── references.rs (551 lines) ← entity cross-reference extraction
|
||||||
|
│ ├── shutdown.rs ← graceful shutdown via tokio signal
|
||||||
|
│ ├── sync_run.rs (218 lines) ← sync run recording to DB
|
||||||
|
│ ├── time.rs ← time conversion utilities
|
||||||
|
│ ├── timeline.rs (284 lines) ← timeline types + EntityRef
|
||||||
|
│ ├── timeline_collect.rs (695 lines) ← Stage 4: collect events from DB
|
||||||
|
│ ├── timeline_expand.rs (557 lines) ← Stage 3: expand via cross-refs
|
||||||
|
│ └── timeline_seed.rs (552 lines) ← Stage 1: FTS search seeding
|
||||||
|
├── documents/ ← well-organized, 3 focused files
|
||||||
|
├── embedding/ ← well-organized, 6 focused files
|
||||||
|
├── gitlab/ ← well-organized, with transformers/ subdir
|
||||||
|
├── ingestion/ ← well-organized, 8 focused files
|
||||||
|
└── search/ ← well-organized, 5 focused files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1: No-Brainers (Do First)
|
||||||
|
|
||||||
|
### 1.1 Extract `timeline/` from `core/`
|
||||||
|
|
||||||
|
**What:** Move the 4 timeline files into their own top-level module `src/timeline/`.
|
||||||
|
|
||||||
|
**Current location:**
|
||||||
|
- `core/timeline.rs` (284 lines) — types: `EntityRef`, `ExpandedEntityRef`, `TimelineEvent`, `TimelineEventType`, etc.
|
||||||
|
- `core/timeline_seed.rs` (552 lines) — Stage 1: FTS-based seeding
|
||||||
|
- `core/timeline_expand.rs` (557 lines) — Stage 3: cross-reference expansion
|
||||||
|
- `core/timeline_collect.rs` (695 lines) — Stage 4: event collection from DB
|
||||||
|
|
||||||
|
**New structure:**
|
||||||
|
```
|
||||||
|
src/timeline/
|
||||||
|
├── mod.rs ← types (from timeline.rs) + re-exports
|
||||||
|
├── seed.rs ← from timeline_seed.rs
|
||||||
|
├── expand.rs ← from timeline_expand.rs
|
||||||
|
└── collect.rs ← from timeline_collect.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** These 4 files form a cohesive 5-stage pipeline (SEED→HYDRATE→EXPAND→COLLECT→RENDER). They have nothing to do with "core" infrastructure like `db.rs`, `config.rs`, or `error.rs`. They only import from `core::error`, `core::time`, and `search::fts` — all of which remain accessible via `crate::core::*` and `crate::search::*` after the move.
|
||||||
|
|
||||||
|
**Import changes needed:**
|
||||||
|
- `cli/commands/timeline.rs`: `use crate::core::timeline::*` → `use crate::timeline::*`, same for `timeline_seed`, `timeline_expand`, `timeline_collect`
|
||||||
|
- `core/mod.rs`: remove the 4 `pub mod timeline*` lines
|
||||||
|
- `lib.rs`: add `pub mod timeline;`
|
||||||
|
|
||||||
|
**Risk: LOW** — Only 1 consumer (`cli/commands/timeline.rs`) + internal cross-references between the 4 files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Extract `xref/` (cross-reference extraction) from `core/`
|
||||||
|
|
||||||
|
**What:** Move `note_parser.rs` and `references.rs` into `src/xref/`.
|
||||||
|
|
||||||
|
**Current location:**
|
||||||
|
- `core/note_parser.rs` (563 lines) — parses note bodies for "mentioned in group/repo#123" patterns, persists to `note_cross_references` table
|
||||||
|
- `core/references.rs` (551 lines) — extracts entity references from state events and closing MRs, writes to `entity_references` table
|
||||||
|
|
||||||
|
**New structure:**
|
||||||
|
```
|
||||||
|
src/xref/
|
||||||
|
├── mod.rs ← re-exports
|
||||||
|
├── note_parser.rs ← from core/note_parser.rs
|
||||||
|
└── references.rs ← from core/references.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** These files implement a specific domain concept — extracting and persisting cross-references between issues and MRs. They are not "core infrastructure." They're consumed by `ingestion/orchestrator.rs` for the cross-reference extraction phase, and the data they produce is consumed by the timeline pipeline. Putting them in their own module makes the data flow clearer: `ingestion → xref → timeline`.
|
||||||
|
|
||||||
|
**Import changes needed:**
|
||||||
|
- `ingestion/orchestrator.rs`: `use crate::core::references::*` → `use crate::xref::references::*`
|
||||||
|
- `ingestion/orchestrator.rs`: `use crate::core::note_parser::*` (if used directly — needs verification) → `use crate::xref::*`
|
||||||
|
- `core/mod.rs`: remove `pub mod note_parser; pub mod references;`
|
||||||
|
- `lib.rs`: add `pub mod xref;`
|
||||||
|
- Internal: the files use `super::error::Result` and `super::time::now_ms` which become `crate::core::error::Result` and `crate::core::time::now_ms`
|
||||||
|
|
||||||
|
**Risk: LOW** — 2-3 consumers at most. The files already use `super::` internally which just needs updating to `crate::core::`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2: Good Improvements (Do After Tier 1)
|
||||||
|
|
||||||
|
### 2.1 Group ingestion-adjacent DB operations
|
||||||
|
|
||||||
|
**What:** Move `events_db.rs`, `dependent_queue.rs`, `payloads.rs`, and `sync_run.rs` from `core/` into `ingestion/` since they exclusively serve the ingestion pipeline.
|
||||||
|
|
||||||
|
**Current consumers:**
|
||||||
|
- `events_db.rs` → only used by `cli/commands/count.rs` (for event counts)
|
||||||
|
- `dependent_queue.rs` → only used by `ingestion/orchestrator.rs` and `main.rs` (to release locked jobs)
|
||||||
|
- `payloads.rs` → only used by `ingestion/discussions.rs`, `ingestion/issues.rs`, `ingestion/merge_requests.rs`, `ingestion/mr_discussions.rs`
|
||||||
|
- `sync_run.rs` → only used by `cli/commands/sync.rs` and `cli/commands/sync_status.rs`
|
||||||
|
|
||||||
|
**New structure:**
|
||||||
|
```
|
||||||
|
src/ingestion/
|
||||||
|
├── (existing files...)
|
||||||
|
├── events_db.rs ← from core/events_db.rs
|
||||||
|
├── dependent_queue.rs ← from core/dependent_queue.rs
|
||||||
|
├── payloads.rs ← from core/payloads.rs
|
||||||
|
└── sync_run.rs ← from core/sync_run.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** All 4 files exist to support the ingestion pipeline:
|
||||||
|
- `events_db.rs` upserts resource state/label/milestone events fetched during ingestion
|
||||||
|
- `dependent_queue.rs` manages the job queue that drives incremental discussion fetching
|
||||||
|
- `payloads.rs` stores the raw JSON payloads fetched from GitLab
|
||||||
|
- `sync_run.rs` records when syncs start/finish and their metrics
|
||||||
|
|
||||||
|
When you're looking for "how does ingestion work?", you'd naturally look in `ingestion/`. Having these scattered in `core/` requires knowing the hidden dependency.
|
||||||
|
|
||||||
|
**Import changes needed:**
|
||||||
|
- `events_db.rs`: 1 consumer in `cli/commands/count.rs` changes from `crate::core::events_db` → `crate::ingestion::events_db`
|
||||||
|
- `dependent_queue.rs`: 2 consumers — `ingestion/orchestrator.rs` (becomes `super::dependent_queue`) and `main.rs`
|
||||||
|
- `payloads.rs`: 4 consumers in `ingestion/*.rs` (become `super::payloads`)
|
||||||
|
- `sync_run.rs`: 2 consumers in `cli/commands/sync.rs` and `sync_status.rs`
|
||||||
|
- Internal references change from `super::error` / `super::time` to `crate::core::error` / `crate::core::time`
|
||||||
|
|
||||||
|
**Risk: MEDIUM** — More import changes, but all straightforward. The internal `super::` references need the most attention.
|
||||||
|
|
||||||
|
**Alternatively:** If moving feels like too much churn, a lighter option is to create `core/ingestion_db.rs` that re-exports from these 4 files, making the grouping visible without moving files. But I think the move is cleaner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Split `cli/mod.rs` — move arg structs to their command files
|
||||||
|
|
||||||
|
**What:** Move each `*Args` struct from `cli/mod.rs` into the corresponding `cli/commands/*.rs` file. Keep `Cli` struct, `Commands` enum, and `detect_robot_mode_from_env()` in `cli/mod.rs`.
|
||||||
|
|
||||||
|
**Currently `cli/mod.rs` (949 lines) contains:**
|
||||||
|
- `Cli` struct (81 lines) — the root clap parser
|
||||||
|
- `Commands` enum (193 lines) — all subcommand variants
|
||||||
|
- `IssuesArgs` (86 lines) → move to `commands/list.rs` or stay near issues handling
|
||||||
|
- `MrsArgs` (93 lines) → move to `commands/list.rs` or stay near MRs handling
|
||||||
|
- `NotesArgs` (99 lines) → move to `commands/list.rs`
|
||||||
|
- `IngestArgs` (33 lines) → move to `commands/ingest.rs`
|
||||||
|
- `StatsArgs` (19 lines) → move to `commands/stats.rs`
|
||||||
|
- `SearchArgs` (58 lines) → move to `commands/search.rs`
|
||||||
|
- `GenerateDocsArgs` (9 lines) → move to `commands/generate_docs.rs`
|
||||||
|
- `SyncArgs` (39 lines) → move to `commands/sync.rs`
|
||||||
|
- `EmbedArgs` (15 lines) → move to `commands/embed.rs`
|
||||||
|
- `TimelineArgs` (53 lines) → move to `commands/timeline.rs`
|
||||||
|
- `WhoArgs` (76 lines) → move to `commands/who.rs`
|
||||||
|
- `CountArgs` (9 lines) → move to `commands/count.rs`
|
||||||
|
|
||||||
|
**After refactoring, `cli/mod.rs` shrinks to ~300 lines** (just `Cli` + `Commands` + the inlined variants like `Init`, `Drift`, `Backup`, `Reset`).
|
||||||
|
|
||||||
|
**Rationale:** When adding a new flag to the `who` command, you currently have to edit `cli/mod.rs` (the args struct), `cli/commands/who.rs` (the implementation), and `main.rs` (the dispatch). If the args struct lives in `commands/who.rs`, you only need two files. This is the standard pattern in mature clap-based Rust CLIs.
|
||||||
|
|
||||||
|
**Import changes needed:**
|
||||||
|
- `main.rs` currently does `use lore::cli::{..., WhoArgs, ...}` — these would become `use lore::cli::commands::{..., WhoArgs, ...}` or the `commands/mod.rs` re-exports them
|
||||||
|
- Each `commands/*.rs` gets its own `#[derive(Parser)]` struct
|
||||||
|
- `Commands` enum in `cli/mod.rs` keeps using the types but imports from `commands::*`
|
||||||
|
|
||||||
|
**Risk: MEDIUM** — Lots of `use` path changes in `main.rs`, but purely mechanical. No logic changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3: Consider Later
|
||||||
|
|
||||||
|
### 3.1 Split `main.rs` (2713 lines)
|
||||||
|
|
||||||
|
**The problem:** `main.rs` contains `main()`, ~30 `handle_*` functions, error handling, clap error formatting, fuzzy command matching, and the `robot-docs` JSON manifest (a 400+ line inline JSON literal).
|
||||||
|
|
||||||
|
**Possible approach:**
|
||||||
|
- Extract `handle_*` functions into `cli/dispatch.rs` (the routing layer)
|
||||||
|
- Extract error handling into `cli/errors.rs`
|
||||||
|
- Extract `handle_robot_docs` + the JSON manifest into `cli/robot_docs.rs`
|
||||||
|
- Keep `main()` in `main.rs` at ~150 lines (just the tracing setup + dispatch call)
|
||||||
|
|
||||||
|
**Why Tier 3:** This is the messiest split. The handler functions depend on the `cli::commands::*` functions AND the `cli::robot::*` helpers AND direct `std::process::exit` calls. Making this work cleanly requires careful thought about the error boundary between `main.rs` (binary) and `lib.rs` (library).
|
||||||
|
|
||||||
|
**Risk: HIGH** — Every handler function touches `robot_mode`, constructs its own timer, opens the DB, and manages error display. The boilerplate is high but consistent, so splitting would just move it around without reducing complexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Split `cli/commands/who.rs` (6067 lines)
|
||||||
|
|
||||||
|
**The problem:** This file implements 5 distinct modes (expert, workload, active, overlap, reviews), each with its own query, scoring model, and output formatting. It also includes the time-decay scoring model (~500 lines) and per-MR detail breakdown logic.
|
||||||
|
|
||||||
|
**Possible split:**
|
||||||
|
```
|
||||||
|
src/cli/commands/who/
|
||||||
|
├── mod.rs ← WhoRun dispatcher, shared types
|
||||||
|
├── expert.rs ← expert mode (path-based file expertise lookup)
|
||||||
|
├── workload.rs ← workload mode (user's assigned issues/MRs)
|
||||||
|
├── active.rs ← active discussions mode
|
||||||
|
├── overlap.rs ← file overlap between users
|
||||||
|
├── reviews.rs ← review pattern analysis
|
||||||
|
└── scoring.rs ← time-decay expert scoring model
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Tier 3:** The 5 modes share many helper functions, database connection patterns, and output formatting logic. Splitting would require carefully identifying the shared helpers and deciding where they live. The file is big but internally consistent — the modes use a shared dispatcher pattern and common types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Split `cli/commands/list.rs` (2931 lines)
|
||||||
|
|
||||||
|
**The problem:** This file handles issue listing, MR listing, AND note listing — three related but distinct operations with separate query builders, output formatters, and test suites.
|
||||||
|
|
||||||
|
**Possible split:**
|
||||||
|
```
|
||||||
|
src/cli/commands/
|
||||||
|
├── list_issues.rs ← issue listing + query builder
|
||||||
|
├── list_mrs.rs ← MR listing + query builder
|
||||||
|
├── list_notes.rs ← note listing + query builder
|
||||||
|
└── list.rs ← shared types (ListFilters, etc.) + re-exports
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Tier 3:** Same issue as `who.rs` — the three listing modes share query building patterns, field selection logic, and sorting code. Splitting requires identifying and extracting the shared pieces first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files NOT Recommended to Move
|
||||||
|
|
||||||
|
These files belong exactly where they are:
|
||||||
|
|
||||||
|
| File | Why it belongs in `core/` |
|
||||||
|
|------|--------------------------|
|
||||||
|
| `config.rs` | Config types used by nearly everything |
|
||||||
|
| `db.rs` | Database connection + migrations — foundational |
|
||||||
|
| `error.rs` | Error types used by every module |
|
||||||
|
| `paths.rs` | File path resolution — infrastructure |
|
||||||
|
| `logging.rs` | Tracing setup — infrastructure |
|
||||||
|
| `lock.rs` | Filesystem sync lock — infrastructure |
|
||||||
|
| `shutdown.rs` | Graceful shutdown signal — infrastructure |
|
||||||
|
| `backoff.rs` | Retry math — infrastructure |
|
||||||
|
| `time.rs` | Time conversion — used everywhere |
|
||||||
|
| `metrics.rs` | Tracing metrics layer — infrastructure |
|
||||||
|
| `project.rs` | Fuzzy project resolution — used by 8+ consumers across modules |
|
||||||
|
|
||||||
|
These files are legitimate "core infrastructure" used across multiple modules. Moving them would create import churn with no clarity gain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files NOT Recommended to Split/Merge
|
||||||
|
|
||||||
|
| File | Why leave it alone |
|
||||||
|
|------|-------------------|
|
||||||
|
| `documents/extractor.rs` (2341 lines) | One cohesive extractor per entity type — the size comes from per-type formatting logic, not mixed concerns |
|
||||||
|
| `ingestion/orchestrator.rs` (1703 lines) | Single orchestration flow — splitting would scatter the pipeline |
|
||||||
|
| `gitlab/graphql.rs` (1293 lines) | GraphQL client with adaptive paging — cohesive |
|
||||||
|
| `gitlab/client.rs` (851 lines) | REST client with all endpoints — cohesive |
|
||||||
|
| `cli/autocorrect.rs` (945 lines) | Correction registry + fuzzy matching — splitting gains nothing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Final Structure (Tiers 1+2)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs (2713 lines — unchanged for now)
|
||||||
|
├── lib.rs (adds: pub mod timeline; pub mod xref;)
|
||||||
|
├── cli/
|
||||||
|
│ ├── mod.rs (~300 lines — Cli + Commands only, args moved out)
|
||||||
|
│ ├── autocorrect.rs (unchanged)
|
||||||
|
│ ├── progress.rs (unchanged)
|
||||||
|
│ ├── robot.rs (unchanged)
|
||||||
|
│ └── commands/
|
||||||
|
│ ├── mod.rs (re-exports + WhoArgs, IssuesArgs, etc.)
|
||||||
|
│ ├── (all existing files — unchanged but with args structs moved in)
|
||||||
|
│ └── ...
|
||||||
|
├── core/ (slimmed: 14 files → infrastructure only)
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── backoff.rs
|
||||||
|
│ ├── config.rs
|
||||||
|
│ ├── db.rs
|
||||||
|
│ ├── error.rs
|
||||||
|
│ ├── lock.rs
|
||||||
|
│ ├── logging.rs
|
||||||
|
│ ├── metrics.rs
|
||||||
|
│ ├── paths.rs
|
||||||
|
│ ├── project.rs
|
||||||
|
│ ├── shutdown.rs
|
||||||
|
│ └── time.rs
|
||||||
|
├── timeline/ (NEW — extracted from core/)
|
||||||
|
│ ├── mod.rs (types from core/timeline.rs)
|
||||||
|
│ ├── seed.rs (from core/timeline_seed.rs)
|
||||||
|
│ ├── expand.rs (from core/timeline_expand.rs)
|
||||||
|
│ └── collect.rs (from core/timeline_collect.rs)
|
||||||
|
├── xref/ (NEW — extracted from core/)
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── note_parser.rs (from core/note_parser.rs)
|
||||||
|
│ └── references.rs (from core/references.rs)
|
||||||
|
├── ingestion/ (gains 4 files from core/)
|
||||||
|
│ ├── (existing files...)
|
||||||
|
│ ├── events_db.rs (from core/events_db.rs)
|
||||||
|
│ ├── dependent_queue.rs (from core/dependent_queue.rs)
|
||||||
|
│ ├── payloads.rs (from core/payloads.rs)
|
||||||
|
│ └── sync_run.rs (from core/sync_run.rs)
|
||||||
|
├── documents/ (unchanged)
|
||||||
|
├── embedding/ (unchanged)
|
||||||
|
├── gitlab/ (unchanged)
|
||||||
|
└── search/ (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import Change Tracking
|
||||||
|
|
||||||
|
### Tier 1.1: Timeline extraction
|
||||||
|
|
||||||
|
| Consumer file | Old import | New import |
|
||||||
|
|---------------|-----------|------------|
|
||||||
|
| `cli/commands/timeline.rs:10-15` | `crate::core::timeline::*` | `crate::timeline::*` |
|
||||||
|
| `cli/commands/timeline.rs:13` | `crate::core::timeline_collect::collect_events` | `crate::timeline::collect_events` (or `crate::timeline::collect::collect_events`) |
|
||||||
|
| `cli/commands/timeline.rs:14` | `crate::core::timeline_expand::expand_timeline` | `crate::timeline::expand_timeline` |
|
||||||
|
| `cli/commands/timeline.rs:15` | `crate::core::timeline_seed::seed_timeline` | `crate::timeline::seed_timeline` |
|
||||||
|
| `core/timeline_seed.rs:7-8` | `super::timeline::*` | `super::*` (or `crate::timeline::*` depending on structure) |
|
||||||
|
| `core/timeline_expand.rs:6` | `super::timeline::*` | `super::*` |
|
||||||
|
| `core/timeline_collect.rs:4` | `super::timeline::*` | `super::*` |
|
||||||
|
| `core/timeline_seed.rs:8` | `crate::search::*` | `crate::search::*` (no change) |
|
||||||
|
| `core/timeline_seed.rs:6-7` | `super::error::Result` | `crate::core::error::Result` |
|
||||||
|
| `core/timeline_expand.rs:5` | `super::error::Result` | `crate::core::error::Result` |
|
||||||
|
| `core/timeline_collect.rs:3` | `super::error::*` | `crate::core::error::*` |
|
||||||
|
|
||||||
|
### Tier 1.2: Cross-reference extraction
|
||||||
|
|
||||||
|
| Consumer file | Old import | New import |
|
||||||
|
|---------------|-----------|------------|
|
||||||
|
| `ingestion/orchestrator.rs:10-12` | `crate::core::references::*` | `crate::xref::references::*` |
|
||||||
|
| `core/note_parser.rs:7-8` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||||
|
| `core/references.rs:4-5` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||||
|
|
||||||
|
### Tier 2.1: Ingestion-adjacent DB ops
|
||||||
|
|
||||||
|
| Consumer file | Old import | New import |
|
||||||
|
|---------------|-----------|------------|
|
||||||
|
| `cli/commands/count.rs:9` | `crate::core::events_db::*` | `crate::ingestion::events_db::*` |
|
||||||
|
| `ingestion/orchestrator.rs:6-8` | `crate::core::dependent_queue::*` | `super::dependent_queue::*` |
|
||||||
|
| `main.rs:37` | `crate::core::dependent_queue::release_all_locked_jobs` | `crate::ingestion::dependent_queue::release_all_locked_jobs` |
|
||||||
|
| `ingestion/discussions.rs:7` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||||
|
| `ingestion/issues.rs:9` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||||
|
| `ingestion/merge_requests.rs:8` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||||
|
| `ingestion/mr_discussions.rs:7` | `crate::core::payloads::*` | `super::payloads::*` |
|
||||||
|
| `cli/commands/sync.rs` | (uses `crate::core::sync_run::*`) | `crate::ingestion::sync_run::*` |
|
||||||
|
| `cli/commands/sync_status.rs` | (uses `crate::core::sync_run::*` or `crate::core::metrics::*`) | check and update |
|
||||||
|
| Internal: `events_db.rs:4-5` | `super::error::*`, `super::time::*` | `crate::core::error::*`, `crate::core::time::*` |
|
||||||
|
| Internal: `dependent_queue.rs:5-6` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||||
|
| Internal: `payloads.rs:9-10` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` |
|
||||||
|
| Internal: `sync_run.rs:2-4` | `super::error::*`, `super::metrics::*`, `super::time::*` | `crate::core::error::*`, `crate::core::metrics::*`, `crate::core::time::*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. **Tier 1.1** — Extract timeline → `src/timeline/` (LOW risk, 1 consumer)
|
||||||
|
2. **Tier 1.2** — Extract xref → `src/xref/` (LOW risk, 1-2 consumers)
|
||||||
|
3. **Cargo check + clippy + test** after each tier
|
||||||
|
4. **Tier 2.1** — Move ingestion DB ops (MEDIUM risk, more consumers)
|
||||||
|
5. **Cargo check + clippy + test**
|
||||||
|
6. **Tier 2.2** — Split `cli/mod.rs` args (MEDIUM risk, mostly mechanical)
|
||||||
|
7. **Cargo check + clippy + test + fmt**
|
||||||
|
|
||||||
|
Each tier should be its own commit for easy rollback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What This Achieves
|
||||||
|
|
||||||
|
**Before:** A developer looking at `core/` sees 22 files and has to mentally sort "infrastructure vs. domain logic vs. pipeline stage." The timeline pipeline is invisible unless you know to look in `core/`.
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- `core/` has 12 files, all clearly infrastructure (db, config, error, paths, logging, lock, shutdown, backoff, time, metrics, project)
|
||||||
|
- `timeline/` is a discoverable first-class module showing the 5-stage pipeline
|
||||||
|
- `xref/` makes the cross-reference extraction domain visible
|
||||||
|
- `ingestion/` contains everything related to data fetching: the orchestrator, entity ingestors, AND their supporting DB operations
|
||||||
|
- `cli/mod.rs` is lean — just the top-level Cli struct and Commands enum
|
||||||
|
|
||||||
|
A new developer (or coding agent) can now answer "where is the timeline code?" → `src/timeline/`, "where is ingestion?" → `src/ingestion/`, "where is cross-reference extraction?" → `src/xref/`, without needing institutional knowledge.
|
||||||
183
README.md
183
README.md
@@ -19,7 +19,10 @@ Local GitLab data management with semantic search, people intelligence, and temp
|
|||||||
- **Cross-reference tracking**: Automatic extraction of "closes", "mentioned" relationships between MRs and issues
|
- **Cross-reference tracking**: Automatic extraction of "closes", "mentioned" relationships between MRs and issues
|
||||||
- **Work item status enrichment**: Fetches issue statuses (e.g., "To do", "In progress", "Done") from GitLab's GraphQL API with adaptive page sizing, color-coded display, and case-insensitive filtering
|
- **Work item status enrichment**: Fetches issue statuses (e.g., "To do", "In progress", "Done") from GitLab's GraphQL API with adaptive page sizing, color-coded display, and case-insensitive filtering
|
||||||
- **Resource event history**: Tracks state changes, label events, and milestone events for issues and MRs
|
- **Resource event history**: Tracks state changes, label events, and milestone events for issues and MRs
|
||||||
|
- **Note querying**: Rich filtering over discussion notes by author, type, path, resolution status, time range, and body content
|
||||||
|
- **Discussion drift detection**: Semantic analysis of how discussions diverge from original issue intent
|
||||||
- **Robot mode**: Machine-readable JSON output with structured errors, meaningful exit codes, and actionable recovery steps
|
- **Robot mode**: Machine-readable JSON output with structured errors, meaningful exit codes, and actionable recovery steps
|
||||||
|
- **Error tolerance**: Auto-corrects common CLI mistakes (case, typos, single-dash flags, value casing) with teaching feedback
|
||||||
- **Observability**: Verbosity controls, JSON log format, structured metrics, and stage timing
|
- **Observability**: Verbosity controls, JSON log format, structured metrics, and stage timing
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -71,6 +74,12 @@ lore who @asmith
|
|||||||
# Timeline of events related to deployments
|
# Timeline of events related to deployments
|
||||||
lore timeline "deployment"
|
lore timeline "deployment"
|
||||||
|
|
||||||
|
# Timeline for a specific issue
|
||||||
|
lore timeline issue:42
|
||||||
|
|
||||||
|
# Query notes by author
|
||||||
|
lore notes --author alice --since 7d
|
||||||
|
|
||||||
# Robot mode (machine-readable JSON)
|
# Robot mode (machine-readable JSON)
|
||||||
lore -J issues -n 5 | jq .
|
lore -J issues -n 5 | jq .
|
||||||
```
|
```
|
||||||
@@ -91,6 +100,7 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
|
|||||||
{ "path": "group/project" },
|
{ "path": "group/project" },
|
||||||
{ "path": "other-group/other-project" }
|
{ "path": "other-group/other-project" }
|
||||||
],
|
],
|
||||||
|
"defaultProject": "group/project",
|
||||||
"sync": {
|
"sync": {
|
||||||
"backfillDays": 14,
|
"backfillDays": 14,
|
||||||
"staleLockMinutes": 10,
|
"staleLockMinutes": 10,
|
||||||
@@ -108,6 +118,15 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
|
|||||||
"model": "nomic-embed-text",
|
"model": "nomic-embed-text",
|
||||||
"baseUrl": "http://localhost:11434",
|
"baseUrl": "http://localhost:11434",
|
||||||
"concurrency": 4
|
"concurrency": 4
|
||||||
|
},
|
||||||
|
"scoring": {
|
||||||
|
"authorWeight": 25,
|
||||||
|
"reviewerWeight": 10,
|
||||||
|
"noteBonus": 1,
|
||||||
|
"authorHalfLifeDays": 180,
|
||||||
|
"reviewerHalfLifeDays": 90,
|
||||||
|
"noteHalfLifeDays": 45,
|
||||||
|
"excludedUsernames": ["bot-user"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -119,6 +138,7 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
|
|||||||
| `gitlab` | `baseUrl` | -- | GitLab instance URL (required) |
|
| `gitlab` | `baseUrl` | -- | GitLab instance URL (required) |
|
||||||
| `gitlab` | `tokenEnvVar` | `GITLAB_TOKEN` | Environment variable containing API token |
|
| `gitlab` | `tokenEnvVar` | `GITLAB_TOKEN` | Environment variable containing API token |
|
||||||
| `projects` | `path` | -- | Project path (e.g., `group/project`) |
|
| `projects` | `path` | -- | Project path (e.g., `group/project`) |
|
||||||
|
| *(top-level)* | `defaultProject` | none | Fallback project path used when `-p` is omitted. Must match a configured project path (exact or suffix). CLI `-p` always overrides. |
|
||||||
| `sync` | `backfillDays` | `14` | Days to backfill on initial sync |
|
| `sync` | `backfillDays` | `14` | Days to backfill on initial sync |
|
||||||
| `sync` | `staleLockMinutes` | `10` | Minutes before sync lock considered stale |
|
| `sync` | `staleLockMinutes` | `10` | Minutes before sync lock considered stale |
|
||||||
| `sync` | `heartbeatIntervalSeconds` | `30` | Frequency of lock heartbeat updates |
|
| `sync` | `heartbeatIntervalSeconds` | `30` | Frequency of lock heartbeat updates |
|
||||||
@@ -133,6 +153,15 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
|
|||||||
| `embedding` | `model` | `nomic-embed-text` | Model name for embeddings |
|
| `embedding` | `model` | `nomic-embed-text` | Model name for embeddings |
|
||||||
| `embedding` | `baseUrl` | `http://localhost:11434` | Ollama server URL |
|
| `embedding` | `baseUrl` | `http://localhost:11434` | Ollama server URL |
|
||||||
| `embedding` | `concurrency` | `4` | Concurrent embedding requests |
|
| `embedding` | `concurrency` | `4` | Concurrent embedding requests |
|
||||||
|
| `scoring` | `authorWeight` | `25` | Points per MR where the user authored code touching the path |
|
||||||
|
| `scoring` | `reviewerWeight` | `10` | Points per MR where the user reviewed code touching the path |
|
||||||
|
| `scoring` | `noteBonus` | `1` | Bonus per inline review comment (DiffNote) |
|
||||||
|
| `scoring` | `reviewerAssignmentWeight` | `3` | Points per MR where the user was assigned as reviewer |
|
||||||
|
| `scoring` | `authorHalfLifeDays` | `180` | Half-life in days for author contribution decay |
|
||||||
|
| `scoring` | `reviewerHalfLifeDays` | `90` | Half-life in days for reviewer contribution decay |
|
||||||
|
| `scoring` | `noteHalfLifeDays` | `45` | Half-life in days for note/comment decay |
|
||||||
|
| `scoring` | `closedMrMultiplier` | `0.5` | Score multiplier for closed (not merged) MRs |
|
||||||
|
| `scoring` | `excludedUsernames` | `[]` | Usernames excluded from expert results (e.g., bots) |
|
||||||
|
|
||||||
### Config File Resolution
|
### Config File Resolution
|
||||||
|
|
||||||
@@ -204,7 +233,7 @@ When showing a single issue (e.g., `lore issues 123`), output includes: title, d
|
|||||||
|
|
||||||
#### Project Resolution
|
#### Project Resolution
|
||||||
|
|
||||||
The `-p` / `--project` flag uses cascading match logic across all commands:
|
When `-p` / `--project` is omitted, the `defaultProject` from config is used as a fallback. If neither is set, results span all configured projects. When a project is specified (via `-p` or config default), it uses cascading match logic across all commands:
|
||||||
|
|
||||||
1. **Exact match**: `group/project`
|
1. **Exact match**: `group/project`
|
||||||
2. **Case-insensitive**: `Group/Project`
|
2. **Case-insensitive**: `Group/Project`
|
||||||
@@ -260,18 +289,21 @@ lore search "login flow" --mode semantic # Vector similarity only
|
|||||||
lore search "auth" --type issue # Filter by source type
|
lore search "auth" --type issue # Filter by source type
|
||||||
lore search "auth" --type mr # MR documents only
|
lore search "auth" --type mr # MR documents only
|
||||||
lore search "auth" --type discussion # Discussion documents only
|
lore search "auth" --type discussion # Discussion documents only
|
||||||
|
lore search "auth" --type note # Individual notes only
|
||||||
lore search "deploy" --author username # Filter by author
|
lore search "deploy" --author username # Filter by author
|
||||||
lore search "deploy" -p group/repo # Filter by project
|
lore search "deploy" -p group/repo # Filter by project
|
||||||
lore search "deploy" --label backend # Filter by label (AND logic)
|
lore search "deploy" --label backend # Filter by label (AND logic)
|
||||||
lore search "deploy" --path src/ # Filter by file path (trailing / for prefix)
|
lore search "deploy" --path src/ # Filter by file path (trailing / for prefix)
|
||||||
lore search "deploy" --after 7d # Created after (7d, 2w, 1m, or YYYY-MM-DD)
|
lore search "deploy" --since 7d # Created since (7d, 2w, 1m, or YYYY-MM-DD)
|
||||||
lore search "deploy" --updated-after 2w # Updated after
|
lore search "deploy" --updated-since 2w # Updated since
|
||||||
lore search "deploy" -n 50 # Limit results (default 20, max 100)
|
lore search "deploy" -n 50 # Limit results (default 20, max 100)
|
||||||
lore search "deploy" --explain # Show ranking explanation per result
|
lore search "deploy" --explain # Show ranking explanation per result
|
||||||
lore search "deploy" --fts-mode raw # Raw FTS5 query syntax (advanced)
|
lore search "deploy" --fts-mode raw # Raw FTS5 query syntax (advanced)
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--fts-mode` flag defaults to `safe`, which sanitizes user input into valid FTS5 queries with automatic fallback. Use `raw` for advanced FTS5 query syntax (AND, OR, NOT, phrase matching, prefix queries).
|
The `--fts-mode` flag defaults to `safe`, which sanitizes user input into valid FTS5 queries with automatic fallback. FTS5 boolean operators (`AND`, `OR`, `NOT`, `NEAR`) are passed through in safe mode, so queries like `"switch AND health"` work without switching to raw mode. Use `raw` for advanced FTS5 query syntax (phrase matching, column filters, prefix queries).
|
||||||
|
|
||||||
|
A progress spinner displays during search, showing the active mode (e.g., `Searching (hybrid)...`). In robot mode, spinners are suppressed for clean JSON output.
|
||||||
|
|
||||||
Requires `lore generate-docs` (or `lore sync`) to have been run at least once. Semantic and hybrid modes require `lore embed` (or `lore sync`) to have generated vector embeddings via Ollama.
|
Requires `lore generate-docs` (or `lore sync`) to have been run at least once. Semantic and hybrid modes require `lore embed` (or `lore sync`) to have generated vector embeddings via Ollama.
|
||||||
|
|
||||||
@@ -281,7 +313,7 @@ People intelligence: discover experts, analyze workloads, review patterns, activ
|
|||||||
|
|
||||||
#### Expert Mode
|
#### Expert Mode
|
||||||
|
|
||||||
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis).
|
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis). Scores use exponential half-life decay so recent contributions count more than older ones. Scoring weights and half-life periods are configurable via the `scoring` config section.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lore who src/features/auth/ # Who knows about this directory?
|
lore who src/features/auth/ # Who knows about this directory?
|
||||||
@@ -290,6 +322,9 @@ lore who --path README.md # Root files need --path flag
|
|||||||
lore who --path Makefile # Dotless root files too
|
lore who --path Makefile # Dotless root files too
|
||||||
lore who src/ --since 3m # Limit to recent 3 months
|
lore who src/ --since 3m # Limit to recent 3 months
|
||||||
lore who src/ -p group/repo # Scope to project
|
lore who src/ -p group/repo # Scope to project
|
||||||
|
lore who src/ --explain-score # Show per-component score breakdown
|
||||||
|
lore who src/ --as-of 30d # Score as if "now" was 30 days ago
|
||||||
|
lore who src/ --include-bots # Include bot users in results
|
||||||
```
|
```
|
||||||
|
|
||||||
The target is auto-detected as a path when it contains `/`. For root files without `/` (e.g., `README.md`), use the `--path` flag. Default time window: 6 months.
|
The target is auto-detected as a path when it contains `/`. For root files without `/` (e.g., `README.md`), use the `--path` flag. Default time window: 6 months.
|
||||||
@@ -346,21 +381,32 @@ Shows: users with touch counts (author vs. review), linked MR references. Defaul
|
|||||||
| `-p` / `--project` | Scope to a project (fuzzy match) |
|
| `-p` / `--project` | Scope to a project (fuzzy match) |
|
||||||
| `--since` | Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. |
|
| `--since` | Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. |
|
||||||
| `-n` / `--limit` | Max results per section (1-500, default 20) |
|
| `-n` / `--limit` | Max results per section (1-500, default 20) |
|
||||||
|
| `--all-history` | Remove the default time window, query all history |
|
||||||
|
| `--detail` | Show per-MR detail breakdown (expert mode only) |
|
||||||
|
| `--explain-score` | Show per-component score breakdown (expert mode only) |
|
||||||
|
| `--as-of` | Score as if "now" is a past date (ISO 8601 or duration like 30d, expert mode only) |
|
||||||
|
| `--include-bots` | Include bot users normally excluded via `scoring.excludedUsernames` |
|
||||||
|
|
||||||
### `lore timeline`
|
### `lore timeline`
|
||||||
|
|
||||||
Reconstruct a chronological timeline of events matching a keyword query. The pipeline discovers related entities through cross-reference graph traversal and assembles a unified, time-ordered event stream.
|
Reconstruct a chronological timeline of events matching a keyword query. The pipeline discovers related entities through cross-reference graph traversal and assembles a unified, time-ordered event stream.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lore timeline "deployment" # Events related to deployments
|
lore timeline "deployment" # Search-based seeding (hybrid search)
|
||||||
|
lore timeline issue:42 # Direct entity seeding by issue IID
|
||||||
|
lore timeline i:42 # Shorthand for issue:42
|
||||||
|
lore timeline mr:99 # Direct entity seeding by MR IID
|
||||||
|
lore timeline m:99 # Shorthand for mr:99
|
||||||
lore timeline "auth" -p group/repo # Scoped to a project
|
lore timeline "auth" -p group/repo # Scoped to a project
|
||||||
lore timeline "auth" --since 30d # Only recent events
|
lore timeline "auth" --since 30d # Only recent events
|
||||||
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
|
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
|
||||||
lore timeline "migration" --expand-mentions # Follow 'mentioned' edges (high fan-out)
|
lore timeline "migration" --no-mentions # Skip 'mentioned' edges (reduces fan-out)
|
||||||
lore timeline "deploy" -n 50 # Limit event count
|
lore timeline "deploy" -n 50 # Limit event count
|
||||||
lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The query can be either a search string (hybrid search finds matching entities) or an entity reference (`issue:N`, `i:N`, `mr:N`, `m:N`) which directly seeds the timeline from a specific entity and its cross-references.
|
||||||
|
|
||||||
#### Flags
|
#### Flags
|
||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
@@ -368,18 +414,21 @@ lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
|||||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||||
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
|
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
|
||||||
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
|
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
|
||||||
| `--expand-mentions` | off | Also follow "mentioned" edges during expansion |
|
| `--no-mentions` | off | Skip "mentioned" edges during expansion (reduces fan-out) |
|
||||||
| `-n` / `--limit` | `100` | Maximum events to display |
|
| `-n` / `--limit` | `100` | Maximum events to display |
|
||||||
| `--max-seeds` | `10` | Maximum seed entities from search |
|
| `--max-seeds` | `10` | Maximum seed entities from search |
|
||||||
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
|
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
|
||||||
| `--max-evidence` | `10` | Maximum evidence notes included |
|
| `--max-evidence` | `10` | Maximum evidence notes included |
|
||||||
|
| `--fields` | all | Select output fields (comma-separated, or 'minimal' preset) |
|
||||||
|
|
||||||
#### Pipeline Stages
|
#### Pipeline Stages
|
||||||
|
|
||||||
1. **SEED** -- Full-text search identifies the most relevant issues and MRs matching the query. Documents are ranked by BM25 relevance.
|
Each stage displays a numbered progress spinner (e.g., `[1/3] Seeding timeline...`). In robot mode, spinners are suppressed for clean JSON output.
|
||||||
2. **HYDRATE** -- Evidence notes are extracted: the top FTS-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced.
|
|
||||||
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and optionally "mentioned" references up to the configured depth.
|
1. **SEED** -- Hybrid search (FTS5 lexical + Ollama vector similarity via Reciprocal Rank Fusion) identifies the most relevant issues and MRs. Falls back to lexical-only if Ollama is unavailable. Discussion notes matching the query are also discovered and attached to their parent entities.
|
||||||
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, and evidence notes. Events are sorted chronologically with stable tiebreaking.
|
2. **HYDRATE** -- Evidence notes are extracted: the top search-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced. Matched discussions are collected as full thread candidates.
|
||||||
|
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and "mentioned" references up to the configured depth. Use `--no-mentions` to exclude "mentioned" edges and reduce fan-out.
|
||||||
|
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, evidence notes, and full discussion threads. Events are sorted chronologically with stable tiebreaking.
|
||||||
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).
|
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).
|
||||||
|
|
||||||
#### Event Types
|
#### Event Types
|
||||||
@@ -393,13 +442,70 @@ lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
|||||||
| `MilestoneSet` | Milestone assigned |
|
| `MilestoneSet` | Milestone assigned |
|
||||||
| `MilestoneRemoved` | Milestone removed |
|
| `MilestoneRemoved` | Milestone removed |
|
||||||
| `Merged` | MR merged (deduplicated against state events) |
|
| `Merged` | MR merged (deduplicated against state events) |
|
||||||
| `NoteEvidence` | Discussion note matched by FTS, with snippet |
|
| `NoteEvidence` | Discussion note matched by search, with snippet |
|
||||||
|
| `DiscussionThread` | Full discussion thread with all non-system notes |
|
||||||
| `CrossReferenced` | Reference to another entity |
|
| `CrossReferenced` | Reference to another entity |
|
||||||
|
|
||||||
#### Unresolved References
|
#### Unresolved References
|
||||||
|
|
||||||
When graph expansion encounters cross-project references to entities not yet synced locally, these are collected as unresolved references in the output. This enables discovery of external dependencies and can inform future sync targets.
|
When graph expansion encounters cross-project references to entities not yet synced locally, these are collected as unresolved references in the output. This enables discovery of external dependencies and can inform future sync targets.
|
||||||
|
|
||||||
|
### `lore notes`
|
||||||
|
|
||||||
|
Query individual notes from discussions with rich filtering options.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore notes # List 50 most recent notes
|
||||||
|
lore notes --author alice --since 7d # Notes by alice in last 7 days
|
||||||
|
lore notes --for-issue 42 -p group/repo # Notes on issue #42
|
||||||
|
lore notes --for-mr 99 -p group/repo # Notes on MR !99
|
||||||
|
lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/
|
||||||
|
lore notes --note-type DiffNote # Only inline code review comments
|
||||||
|
lore notes --contains "TODO" # Substring search in note body
|
||||||
|
lore notes --include-system # Include system-generated notes
|
||||||
|
lore notes --since 2w --until 2024-12-31 # Time-bounded range
|
||||||
|
lore notes --sort updated --asc # Sort by update time, ascending
|
||||||
|
lore notes --format csv # CSV output
|
||||||
|
lore notes --format jsonl # Line-delimited JSON
|
||||||
|
lore notes -o # Open first result in browser
|
||||||
|
|
||||||
|
# Field selection (robot mode)
|
||||||
|
lore -J notes --fields minimal # Compact: id, author_username, body, created_at_iso
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Filters
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-a` / `--author` | Filter by note author username |
|
||||||
|
| `--note-type` | Filter by note type (DiffNote, DiscussionNote) |
|
||||||
|
| `--contains` | Substring search in note body |
|
||||||
|
| `--note-id` | Filter by internal note ID |
|
||||||
|
| `--gitlab-note-id` | Filter by GitLab note ID |
|
||||||
|
| `--discussion-id` | Filter by discussion ID |
|
||||||
|
| `--include-system` | Include system notes (excluded by default) |
|
||||||
|
| `--for-issue` | Notes on a specific issue IID (requires `-p`) |
|
||||||
|
| `--for-mr` | Notes on a specific MR IID (requires `-p`) |
|
||||||
|
| `-p` / `--project` | Scope to a project (fuzzy match) |
|
||||||
|
| `--since` | Notes created since (7d, 2w, 1m, or YYYY-MM-DD) |
|
||||||
|
| `--until` | Notes created until (YYYY-MM-DD, inclusive end-of-day) |
|
||||||
|
| `--path` | Filter by file path (DiffNotes only; trailing `/` for prefix match) |
|
||||||
|
| `--resolution` | Filter by resolution status (`any`, `unresolved`, `resolved`) |
|
||||||
|
| `--sort` | Sort by `created` (default) or `updated` |
|
||||||
|
| `--asc` | Sort ascending (default: descending) |
|
||||||
|
| `--format` | Output format: `table` (default), `json`, `jsonl`, `csv` |
|
||||||
|
| `-o` / `--open` | Open first result in browser |
|
||||||
|
|
||||||
|
### `lore drift`
|
||||||
|
|
||||||
|
Detect discussion divergence from the original intent of an issue by comparing the semantic similarity of discussion content against the issue description.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore drift issues 42 # Check divergence on issue #42
|
||||||
|
lore drift issues 42 --threshold 0.6 # Higher threshold (stricter)
|
||||||
|
lore drift issues 42 -p group/repo # Scope to project
|
||||||
|
```
|
||||||
|
|
||||||
### `lore sync`
|
### `lore sync`
|
||||||
|
|
||||||
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings.
|
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings.
|
||||||
@@ -411,6 +517,7 @@ lore sync --force # Override stale lock
|
|||||||
lore sync --no-embed # Skip embedding step
|
lore sync --no-embed # Skip embedding step
|
||||||
lore sync --no-docs # Skip document regeneration
|
lore sync --no-docs # Skip document regeneration
|
||||||
lore sync --no-events # Skip resource event fetching
|
lore sync --no-events # Skip resource event fetching
|
||||||
|
lore sync --no-file-changes # Skip MR file change fetching
|
||||||
lore sync --dry-run # Preview what would be synced
|
lore sync --dry-run # Preview what would be synced
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -508,12 +615,15 @@ lore init --force # Overwrite existing config
|
|||||||
lore init --non-interactive # Fail if prompts needed
|
lore init --non-interactive # Fail if prompts needed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When multiple projects are configured, `init` prompts whether to set a default project (used when `-p` is omitted). This can also be set via the `--default-project` flag.
|
||||||
|
|
||||||
In robot mode, `init` supports non-interactive setup via flags:
|
In robot mode, `init` supports non-interactive setup via flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lore -J init --gitlab-url https://gitlab.com \
|
lore -J init --gitlab-url https://gitlab.com \
|
||||||
--token-env-var GITLAB_TOKEN \
|
--token-env-var GITLAB_TOKEN \
|
||||||
--projects "group/project,other/project"
|
--projects "group/project,other/project" \
|
||||||
|
--default-project group/project
|
||||||
```
|
```
|
||||||
|
|
||||||
### `lore auth`
|
### `lore auth`
|
||||||
@@ -566,6 +676,7 @@ Machine-readable command manifest for agent self-discovery. Returns a JSON schem
|
|||||||
```bash
|
```bash
|
||||||
lore robot-docs # Pretty-printed JSON
|
lore robot-docs # Pretty-printed JSON
|
||||||
lore --robot robot-docs # Compact JSON for parsing
|
lore --robot robot-docs # Compact JSON for parsing
|
||||||
|
lore robot-docs --brief # Omit response_schema (~60% smaller)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `lore version`
|
### `lore version`
|
||||||
@@ -617,7 +728,7 @@ The `actions` array contains executable shell commands an agent can run to recov
|
|||||||
|
|
||||||
### Field Selection
|
### Field Selection
|
||||||
|
|
||||||
The `--fields` flag on `issues` and `mrs` list commands controls which fields appear in the JSON response, reducing token usage for AI agent workflows:
|
The `--fields` flag controls which fields appear in the JSON response, reducing token usage for AI agent workflows. Supported on `issues`, `mrs`, `notes`, `search`, `timeline`, and `who` list commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Minimal preset (~60% fewer tokens)
|
# Minimal preset (~60% fewer tokens)
|
||||||
@@ -634,6 +745,48 @@ Valid fields for issues: `iid`, `title`, `state`, `author_username`, `labels`, `
|
|||||||
|
|
||||||
Valid fields for MRs: `iid`, `title`, `state`, `author_username`, `labels`, `draft`, `target_branch`, `source_branch`, `discussion_count`, `unresolved_count`, `created_at_iso`, `updated_at_iso`, `web_url`, `project_path`, `reviewers`
|
Valid fields for MRs: `iid`, `title`, `state`, `author_username`, `labels`, `draft`, `target_branch`, `source_branch`, `discussion_count`, `unresolved_count`, `created_at_iso`, `updated_at_iso`, `web_url`, `project_path`, `reviewers`
|
||||||
|
|
||||||
|
### Error Tolerance
|
||||||
|
|
||||||
|
The CLI auto-corrects common mistakes before parsing, emitting a teaching note to stderr. Corrections work in both human and robot modes:
|
||||||
|
|
||||||
|
| Correction | Example | Mode |
|
||||||
|
|-----------|---------|------|
|
||||||
|
| Single-dash long flag | `-robot` -> `--robot` | All |
|
||||||
|
| Case normalization | `--Robot` -> `--robot` | All |
|
||||||
|
| Flag prefix expansion | `--proj` -> `--project` (unambiguous only) | All |
|
||||||
|
| Fuzzy flag match | `--projct` -> `--project` | All (threshold 0.9 in robot, 0.8 in human) |
|
||||||
|
| Subcommand alias | `merge_requests` -> `mrs`, `robotdocs` -> `robot-docs` | All |
|
||||||
|
| Value normalization | `--state Opened` -> `--state opened` | All |
|
||||||
|
| Value fuzzy match | `--state opend` -> `--state opened` | All |
|
||||||
|
| Subcommand prefix | `lore iss` -> `lore issues` (unambiguous only, via clap) | All |
|
||||||
|
|
||||||
|
In robot mode, corrections emit structured JSON to stderr:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"warning":{"type":"ARG_CORRECTED","corrections":[...],"teaching":["Use double-dash for long flags: --robot (not -robot)"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
When a command or flag is still unrecognized after corrections, the error response includes a fuzzy suggestion and, for enum-like flags, lists valid values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error":{"code":"UNKNOWN_COMMAND","message":"...","suggestion":"Did you mean 'lore issues'? Example: lore --robot issues -n 10. Run 'lore robot-docs' for all commands"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Aliases
|
||||||
|
|
||||||
|
Commands accept aliases for common variations:
|
||||||
|
|
||||||
|
| Primary | Aliases |
|
||||||
|
|---------|---------|
|
||||||
|
| `issues` | `issue` |
|
||||||
|
| `mrs` | `mr`, `merge-requests`, `merge-request` |
|
||||||
|
| `notes` | `note` |
|
||||||
|
| `search` | `find`, `query` |
|
||||||
|
| `stats` | `stat` |
|
||||||
|
| `status` | `st` |
|
||||||
|
|
||||||
|
Unambiguous prefixes also work via subcommand inference (e.g., `lore iss` -> `lore issues`, `lore time` -> `lore timeline`).
|
||||||
|
|
||||||
### Agent Self-Discovery
|
### Agent Self-Discovery
|
||||||
|
|
||||||
The `robot-docs` command provides a complete machine-readable manifest including response schemas for every command:
|
The `robot-docs` command provides a complete machine-readable manifest including response schemas for every command:
|
||||||
|
|||||||
245
docs/diagrams/01-human-flow-map.excalidraw
Normal file
245
docs/diagrams/01-human-flow-map.excalidraw
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 300, "y": 15, "text": "Human User Flow Map", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 220, "y": 53, "text": "15 human workflows mapped to lore commands. Arrows show data dependency.", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "col-trigger", "x": 60, "y": 80, "text": "TRIGGER (Problem)", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-flow", "x": 400, "y": 80, "text": "COMMAND FLOW", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-gap", "x": 880, "y": 80, "text": "GAP", "fontSize": 16, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-daily", "x": 20, "y": 110, "width": 960, "height": 190,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-daily-label", "x": 30, "y": 115, "text": "Daily Operations", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h1-trigger", "x": 30, "y": 140, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H1: Standup prep\n\"What moved overnight?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a1", "x": 230, "y": 165, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd1", "x": 280, "y": 145, "width": 90, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "sync -q", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a2", "x": 370, "y": 165, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd2", "x": 400, "y": 145, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues --since 1d", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a3", "x": 540, "y": 165, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd3", "x": 570, "y": 145, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs --since 1d", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a4", "x": 700, "y": 165, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd4", "x": 730, "y": 145, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @me", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a5", "x": 830, "y": 165, "width": 40, "height": 0,
|
||||||
|
"points": [[0,0],[40,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-gap", "x": 870, "y": 140, "width": 100, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No @me\nNo feed", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h3-trigger", "x": 30, "y": 210, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H3: Incident\n\"Deploy broke prod\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a1", "x": 230, "y": 235, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd1", "x": 280, "y": 215, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "timeline deploy", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a2", "x": 410, "y": 235, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd2", "x": 440, "y": 215, "width": 160, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search deploy --mr", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a3", "x": 600, "y": 235, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd3", "x": 630, "y": 215, "width": 110, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs <iid>", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a4", "x": 740, "y": 235, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd4", "x": 770, "y": 215, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who --overlap", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-planning", "x": 20, "y": 310, "width": 960, "height": 190,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-planning-label", "x": 30, "y": 315, "text": "Planning & Assignment", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h2-trigger", "x": 30, "y": 340, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H2: Sprint plan\n\"What's ready to pick?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h2-a1", "x": 230, "y": 365, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h2-cmd1", "x": 280, "y": 345, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues -s opened -l ready", "fontSize": 13 } },
|
||||||
|
{ "type": "arrow", "id": "h2-a2", "x": 450, "y": 365, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h2-cmd2", "x": 480, "y": 345, "width": 150, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues --has-due", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h2-a3", "x": 630, "y": 365, "width": 230, "height": 0,
|
||||||
|
"points": [[0,0],[230,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h2-gap", "x": 860, "y": 340, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No\n--no-assignee", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h8-trigger", "x": 30, "y": 410, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H8: Assign work\n\"Who has bandwidth?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a1", "x": 230, "y": 435, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-cmd1", "x": 280, "y": 415, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @alice", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a2", "x": 400, "y": 435, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-cmd2", "x": 430, "y": 415, "width": 110, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @bob", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a3", "x": 540, "y": 435, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-cmd3", "x": 570, "y": 415, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @carol...", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a4", "x": 690, "y": 435, "width": 170, "height": 0,
|
||||||
|
"points": [[0,0],[170,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-gap", "x": 860, "y": 410, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No team\nworkload view", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-investigation", "x": 20, "y": 510, "width": 960, "height": 260,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-invest-label", "x": 30, "y": 515, "text": "Investigation & Understanding", "fontSize": 14, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h7-trigger", "x": 30, "y": 540, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H7: Why this way?\n\"Understand a decision\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a1", "x": 230, "y": 565, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-cmd1", "x": 280, "y": 545, "width": 160, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search \"rationale\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a2", "x": 440, "y": 565, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-cmd2", "x": 470, "y": 545, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "timeline --depth 2", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a3", "x": 610, "y": 565, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-cmd3", "x": 640, "y": 545, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues 234", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a4", "x": 740, "y": 565, "width": 120, "height": 0,
|
||||||
|
"points": [[0,0],[120,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-gap", "x": 860, "y": 540, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No per-note\nsearch", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h11-trigger", "x": 30, "y": 610, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H11: Bug lifecycle\n\"Why does #321 reopen?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h11-a1", "x": 230, "y": 635, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h11-cmd1", "x": 280, "y": 615, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues 321", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h11-a2", "x": 400, "y": 635, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h11-cmd2", "x": 430, "y": 615, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "timeline ???", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h11-a3", "x": 560, "y": 635, "width": 300, "height": 0,
|
||||||
|
"points": [[0,0],[300,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h11-gap", "x": 860, "y": 610, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No entity\ntimeline", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h14-trigger", "x": 30, "y": 680, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H14: Prior art?\n\"Was this tried before?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h14-a1", "x": 230, "y": 705, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h14-cmd1", "x": 280, "y": 685, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search \"memory leak\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h14-a2", "x": 450, "y": 705, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h14-cmd2", "x": 480, "y": 685, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs --closed?", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h14-a3", "x": 600, "y": 705, "width": 260, "height": 0,
|
||||||
|
"points": [[0,0],[260,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h14-gap", "x": 860, "y": 680, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No --state\non search", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-people", "x": 20, "y": 780, "width": 960, "height": 190,
|
||||||
|
"backgroundColor": "#e5dbff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#8b5cf6", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-people-label", "x": 30, "y": 785, "text": "People & Expertise", "fontSize": 14, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h4-trigger", "x": 30, "y": 810, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H4: Review prep\n\"Context for MR !789\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a1", "x": 230, "y": 835, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-cmd1", "x": 280, "y": 815, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs 789", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a2", "x": 380, "y": 835, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-cmd2", "x": 410, "y": 815, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who src/auth/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a3", "x": 530, "y": 835, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-cmd3", "x": 560, "y": 815, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search \"auth\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a4", "x": 690, "y": 835, "width": 170, "height": 0,
|
||||||
|
"points": [[0,0],[170,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-gap", "x": 860, "y": 810, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No MR file\nlist output", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h6-trigger", "x": 30, "y": 880, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H6: Find reviewer\n\"Who should review?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a1", "x": 230, "y": 905, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-cmd1", "x": 280, "y": 885, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who src/auth/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a2", "x": 410, "y": 905, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-cmd2", "x": 440, "y": 885, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who src/pay/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a3", "x": 580, "y": 905, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-cmd3", "x": 610, "y": 885, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @candidate", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a4", "x": 750, "y": 905, "width": 110, "height": 0,
|
||||||
|
"points": [[0,0],[110,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-gap", "x": 860, "y": 880, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No multi-\npath query", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "callout-1", "x": 30, "y": 990, "text": "Pattern: Most human flows require 3-5 serial commands. Average gap rate: 73% of flows have at least one.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "callout-2", "x": 30, "y": 1015, "text": "Top optimization: Composite commands (activity feed, team workload) would reduce multi-command flows by ~40%.", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
{ "type": "text", "id": "callout-3", "x": 30, "y": 1040, "text": "Top missing data: MR file changes and entity references are stored but invisible to CLI users.", "fontSize": 14, "strokeColor": "#ef4444" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/01-human-flow-map.png
Normal file
BIN
docs/diagrams/01-human-flow-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
204
docs/diagrams/02-agent-flow-map.excalidraw
Normal file
204
docs/diagrams/02-agent-flow-map.excalidraw
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 320, "y": 15, "text": "AI Agent Flow Map", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 180, "y": 53, "text": "15 agent automation workflows. Agents need structured JSON (-J), exit codes, and field selection.", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "col-trigger", "x": 60, "y": 80, "text": "TRIGGER (Agent Goal)", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-flow", "x": 400, "y": 80, "text": "COMMAND PIPELINE", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-gap", "x": 880, "y": 80, "text": "BLOCKED BY", "fontSize": 16, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-context", "x": 20, "y": 110, "width": 960, "height": 200,
|
||||||
|
"backgroundColor": "#e5dbff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#8b5cf6", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-context-label", "x": 30, "y": 115, "text": "Context Gathering (pre-action)", "fontSize": 14, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a1-trigger", "x": 30, "y": 140, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A1: Pre-edit context\nAbout to modify files", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a1", "x": 230, "y": 165, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd1", "x": 280, "y": 145, "width": 80, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J health", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a2", "x": 360, "y": 165, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd2", "x": 380, "y": 145, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J who src/auth/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a3", "x": 520, "y": 165, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd3", "x": 540, "y": 145, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search \"auth\" -n 10", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a4", "x": 710, "y": 165, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd4", "x": 730, "y": 145, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J who --overlap", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a6-trigger", "x": 30, "y": 210, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A6: Auto-assign reviewers\nBased on file expertise", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a6-a1", "x": 230, "y": 235, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a6-cmd1", "x": 280, "y": 215, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J mrs 456", "fontSize": 14 } },
|
||||||
|
{ "type": "text", "id": "a6-block", "x": 390, "y": 218, "text": "file list not\nin response!", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
{ "type": "arrow", "id": "a6-a2", "x": 380, "y": 245, "width": 480, "height": -10,
|
||||||
|
"points": [[0,0],[480,-10]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a6-gap", "x": 860, "y": 210, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "MR files\nnot exposed", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-report", "x": 20, "y": 320, "width": 960, "height": 200,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-report-label", "x": 30, "y": 325, "text": "Reporting & Synthesis", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a3-trigger", "x": 30, "y": 350, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A3: Sprint status report\n7 queries for 1 report", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a3-a1", "x": 230, "y": 375, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd1", "x": 280, "y": 352, "width": 100, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues -s closed", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd2", "x": 390, "y": 352, "width": 100, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues --status", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd3", "x": 500, "y": 352, "width": 100, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs -s merged", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd4", "x": 610, "y": 352, "width": 80, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs -s open", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd5", "x": 700, "y": 352, "width": 80, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "count x2", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd6", "x": 790, "y": 352, "width": 60, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who", "fontSize": 12 } },
|
||||||
|
{ "type": "arrow", "id": "a3-agap", "x": 850, "y": 370, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a3-gap", "x": 860, "y": 350, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No summary\ncommand", "fontSize": 14 } },
|
||||||
|
{ "type": "text", "id": "a3-note", "x": 280, "y": 395, "text": "7 sequential API calls for one report. A `lore summary` could reduce to 1.", "fontSize": 12, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a7-trigger", "x": 30, "y": 430, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A7: Incident timeline\nPostmortem reconstruction", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a7-a1", "x": 230, "y": 455, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a7-cmd1", "x": 280, "y": 435, "width": 190, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J timeline --depth 2", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a7-a2", "x": 470, "y": 455, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a7-cmd2", "x": 490, "y": 435, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search --since 3d", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a7-a3", "x": 660, "y": 455, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a7-cmd3", "x": 680, "y": 435, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J mrs -s merged", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-discover", "x": 20, "y": 530, "width": 960, "height": 200,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-discover-label", "x": 30, "y": 535, "text": "Discovery & Correlation", "fontSize": 14, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a5-trigger", "x": 30, "y": 560, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A5: PR description\nFind related issues to link", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a5-a1", "x": 230, "y": 585, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a5-cmd1", "x": 280, "y": 565, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search keywords", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a5-a2", "x": 450, "y": 585, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a5-cmd2", "x": 470, "y": 565, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J issues --fields iid,url", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a5-a3", "x": 650, "y": 585, "width": 210, "height": 0,
|
||||||
|
"points": [[0,0],[210,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a5-gap", "x": 860, "y": 560, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No refs\nquery", "fontSize": 14 } },
|
||||||
|
{ "type": "text", "id": "a5-note", "x": 280, "y": 612, "text": "Agent can't ask \"which issues does MR !456 close?\" -- entity_references data exists but isn't queryable.", "fontSize": 12, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a11-trigger", "x": 30, "y": 640, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A11: Knowledge graph\nMap entity relationships", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a11-a1", "x": 230, "y": 665, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a11-cmd1", "x": 280, "y": 645, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search -n 30", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a11-a2", "x": 420, "y": 665, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a11-cmd2", "x": 440, "y": 645, "width": 190, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J timeline --depth 2", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a11-a3", "x": 630, "y": 665, "width": 230, "height": 0,
|
||||||
|
"points": [[0,0],[230,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a11-gap", "x": 860, "y": 640, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No refs\nquery", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-maint", "x": 20, "y": 740, "width": 960, "height": 140,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-maint-label", "x": 30, "y": 745, "text": "Maintenance & Cleanup", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a9-trigger", "x": 30, "y": 770, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A9: Stale issue cleanup\nWeekly backlog hygiene", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a9-a1", "x": 230, "y": 795, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a9-cmd1", "x": 280, "y": 775, "width": 200, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J issues --sort updated --asc", "fontSize": 12 } },
|
||||||
|
{ "type": "arrow", "id": "a9-a2", "x": 480, "y": 795, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a9-cmd2", "x": 500, "y": 775, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "filter client-side", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a9-a3", "x": 620, "y": 795, "width": 240, "height": 0,
|
||||||
|
"points": [[0,0],[240,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a9-gap", "x": 860, "y": 770, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No --before\nNo offset", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a15-trigger", "x": 30, "y": 840, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A15: Conflict detect\n\"Safe to start work?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a15-a1", "x": 230, "y": 865, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a15-cmd1", "x": 280, "y": 845, "width": 110, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J issues 123", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a15-a2", "x": 390, "y": 865, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a15-cmd2", "x": 410, "y": 845, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J who --overlap", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a15-a3", "x": 540, "y": 865, "width": 320, "height": 0,
|
||||||
|
"points": [[0,0],[320,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a15-gap", "x": 860, "y": 840, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No refs +\n--state", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "callout-1", "x": 30, "y": 910, "text": "Agent-specific pain: Agents always use -J and --fields minimal for token efficiency. Every extra query burns tokens.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "callout-2", "x": 30, "y": 935, "text": "Biggest ROI: `lore refs` command would unblock A5, A11, A12, A15 instantly. Data already exists in entity_references table.", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
{ "type": "text", "id": "callout-3", "x": 30, "y": 960, "text": "Token waste: Sprint report (A3) requires 7 calls. A composite `lore summary` could save ~85% of tokens.", "fontSize": 14, "strokeColor": "#ef4444" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/02-agent-flow-map.png
Normal file
BIN
docs/diagrams/02-agent-flow-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
203
docs/diagrams/03-command-coverage.excalidraw
Normal file
203
docs/diagrams/03-command-coverage.excalidraw
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 280, "y": 15, "text": "Command Coverage Heatmap", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 220, "y": 53, "text": "Which commands serve which workflows? Darker = more essential to that flow.", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "col-issues", "x": 260, "y": 85, "text": "issues", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-mrs", "x": 330, "y": 85, "text": "mrs", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-search", "x": 390, "y": 85, "text": "search", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-who", "x": 465, "y": 85, "text": "who", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-timeline", "x": 520, "y": 85, "text": "timeline", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-sync", "x": 600, "y": 85, "text": "sync", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-count", "x": 660, "y": 85, "text": "count", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-status", "x": 720, "y": 85, "text": "status", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-missing", "x": 790, "y": 85, "text": "MISSING?", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-human", "x": 15, "y": 108, "text": "HUMAN FLOWS", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h1-label", "x": 15, "y": 135, "text": "H1 Standup prep", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h1-issues", "x": 255, "y": 130, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h1-mrs", "x": 325, "y": 130, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h1-who", "x": 460, "y": 130, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h1-sync", "x": 595, "y": 130, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h1-gap", "x": 780, "y": 135, "text": "activity feed", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h2-label", "x": 15, "y": 170, "text": "H2 Sprint planning", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h2-issues", "x": 255, "y": 165, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h2-count", "x": 655, "y": 165, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h2-gap", "x": 780, "y": 170, "text": "--no-assignee", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h3-label", "x": 15, "y": 205, "text": "H3 Incident response", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h3-mrs", "x": 325, "y": 200, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-search", "x": 390, "y": 200, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-who", "x": 460, "y": 200, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-timeline", "x": 525, "y": 200, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-sync", "x": 595, "y": 200, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h4-label", "x": 15, "y": 240, "text": "H4 Code review prep", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h4-mrs", "x": 325, "y": 235, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h4-search", "x": 390, "y": 235, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h4-who", "x": 460, "y": 235, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h4-timeline", "x": 525, "y": 235, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h4-gap", "x": 780, "y": 240, "text": "MR file list", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h5-label", "x": 15, "y": 275, "text": "H5 Onboarding", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h5-issues", "x": 255, "y": 270, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-mrs", "x": 325, "y": 270, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-search", "x": 390, "y": 270, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-who", "x": 460, "y": 270, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-timeline", "x": 525, "y": 270, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h6-label", "x": 15, "y": 310, "text": "H6 Find reviewer", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h6-who", "x": 460, "y": 305, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h6-gap", "x": 780, "y": 310, "text": "multi-path who", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h7-label", "x": 15, "y": 345, "text": "H7 Why was this built?", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h7-issues", "x": 255, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h7-mrs", "x": 325, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h7-search", "x": 390, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h7-timeline", "x": 525, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h7-gap", "x": 780, "y": 345, "text": "per-note search", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h8-label", "x": 15, "y": 380, "text": "H8 Team workload", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h8-who", "x": 460, "y": 375, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h8-gap", "x": 780, "y": 380, "text": "team view", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h9-label", "x": 15, "y": 415, "text": "H9 Release notes", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h9-issues", "x": 255, "y": 410, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h9-mrs", "x": 325, "y": 410, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h9-gap", "x": 780, "y": 415, "text": "mrs --milestone", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h10-label", "x": 15, "y": 450, "text": "H10 Stale issues", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h10-issues", "x": 255, "y": 445, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h10-gap", "x": 780, "y": 450, "text": "--updated-before", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h11-label", "x": 15, "y": 485, "text": "H11 Bug lifecycle", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h11-issues", "x": 255, "y": 480, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h11-timeline", "x": 525, "y": 480, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h11-gap", "x": 780, "y": 485, "text": "entity timeline", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h12-label", "x": 15, "y": 520, "text": "H12 Who broke tests?", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h12-search", "x": 390, "y": 515, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h12-who", "x": 460, "y": 515, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h13-label", "x": 15, "y": 555, "text": "H13 Feature tracking", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h13-issues", "x": 255, "y": 550, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h13-mrs", "x": 325, "y": 550, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h13-timeline", "x": 525, "y": 550, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h14-label", "x": 15, "y": 590, "text": "H14 Prior art check", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h14-search", "x": 390, "y": 585, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h14-timeline", "x": 525, "y": 585, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h14-gap", "x": 780, "y": 590, "text": "--state on search", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h15-label", "x": 15, "y": 625, "text": "H15 My discussions", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h15-who", "x": 460, "y": 620, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h15-gap", "x": 780, "y": 625, "text": "participant filter", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "divider", "x": 10, "y": 655, "width": 910, "height": 2, "backgroundColor": "#dee2e6", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-agent", "x": 15, "y": 668, "text": "AI AGENT FLOWS", "fontSize": 14, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a1-label", "x": 15, "y": 695, "text": "A1 Pre-edit context", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a1-mrs", "x": 325, "y": 690, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a1-search", "x": 390, "y": 690, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a1-who", "x": 460, "y": 690, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a2-label", "x": 15, "y": 730, "text": "A2 Auto-triage", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a2-issues", "x": 255, "y": 725, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a2-search", "x": 390, "y": 725, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a2-who", "x": 460, "y": 725, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a2-gap", "x": 780, "y": 730, "text": "detail --fields", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a3-label", "x": 15, "y": 765, "text": "A3 Sprint report", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a3-issues", "x": 255, "y": 760, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a3-mrs", "x": 325, "y": 760, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a3-who", "x": 460, "y": 760, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a3-count", "x": 655, "y": 760, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a3-gap", "x": 780, "y": 765, "text": "summary cmd", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a4-label", "x": 15, "y": 800, "text": "A4 Prior art", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a4-search", "x": 390, "y": 795, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a4-timeline", "x": 525, "y": 795, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a4-gap", "x": 780, "y": 800, "text": "per-note search", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a5-label", "x": 15, "y": 835, "text": "A5 PR description", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a5-issues", "x": 255, "y": 830, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a5-search", "x": 390, "y": 830, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a5-gap", "x": 780, "y": 835, "text": "entity refs query", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a6-label", "x": 15, "y": 870, "text": "A6 Reviewer assign", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a6-mrs", "x": 325, "y": 865, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a6-who", "x": 460, "y": 865, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a6-gap", "x": 780, "y": 870, "text": "MR file list", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a7-label", "x": 15, "y": 905, "text": "A7 Incident timeline", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a7-mrs", "x": 325, "y": 900, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a7-search", "x": 390, "y": 900, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a7-timeline", "x": 525, "y": 900, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a8-label", "x": 15, "y": 940, "text": "A8 Cross-project", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a8-search", "x": 390, "y": 935, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a8-timeline", "x": 525, "y": 935, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a8-gap", "x": 780, "y": 940, "text": "group by project", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a9-label", "x": 15, "y": 975, "text": "A9 Stale cleanup", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a9-issues", "x": 255, "y": 970, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a9-search", "x": 390, "y": 970, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a9-gap", "x": 780, "y": 975, "text": "--updated-before", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a10-label", "x": 15, "y": 1010, "text": "A10 Review context", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a10-mrs", "x": 325, "y": 1005, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a10-who", "x": 460, "y": 1005, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a10-gap", "x": 780, "y": 1010, "text": "MR file list", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a11-label", "x": 15, "y": 1045, "text": "A11 Knowledge graph", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a11-search", "x": 390, "y": 1040, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a11-timeline", "x": 525, "y": 1040, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a11-gap", "x": 780, "y": 1045, "text": "entity refs query", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a12-label", "x": 15, "y": 1080, "text": "A12 Release check", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a12-issues", "x": 255, "y": 1075, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a12-mrs", "x": 325, "y": 1075, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a12-who", "x": 460, "y": 1075, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a12-gap", "x": 780, "y": 1080, "text": "mrs --milestone", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a13-label", "x": 15, "y": 1115, "text": "A13 What changed?", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a13-issues", "x": 255, "y": 1110, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a13-mrs", "x": 325, "y": 1110, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a13-gap", "x": 780, "y": 1115, "text": "state-change filter", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a14-label", "x": 15, "y": 1150, "text": "A14 Meeting prep", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a14-issues", "x": 255, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a14-mrs", "x": 325, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a14-who", "x": 460, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a14-count", "x": 655, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a14-gap", "x": 780, "y": 1150, "text": "summary cmd", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a15-label", "x": 15, "y": 1185, "text": "A15 Conflict detect", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a15-issues", "x": 255, "y": 1180, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a15-mrs", "x": 325, "y": 1180, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a15-who", "x": 460, "y": 1180, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a15-gap", "x": 780, "y": 1185, "text": "entity refs, --state", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "legend-title", "x": 15, "y": 1230, "text": "Legend:", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-essential", "x": 80, "y": 1228, "width": 20, "height": 20, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-essential-t", "x": 105, "y": 1230, "text": "Essential", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-supporting", "x": 190, "y": 1228, "width": 20, "height": 20, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-supporting-t", "x": 215, "y": 1230, "text": "Supporting", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-partial", "x": 310, "y": 1228, "width": 20, "height": 20, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-partial-t", "x": 335, "y": 1230, "text": "Partially blocked", "fontSize": 14 },
|
||||||
|
{ "type": "text", "id": "leg-gap-t", "x": 470, "y": 1230, "text": "Red text = gap", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "insight-1", "x": 15, "y": 1270, "text": "Key insight: `issues` and `search` are the workhorses (used in 20+ flows).", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "insight-2", "x": 15, "y": 1295, "text": "`who` is critical for people questions but siloed from file-change data.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "insight-3", "x": 15, "y": 1320, "text": "`timeline` is powerful but keyword-only seeding limits entity-specific queries.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "insight-4", "x": 15, "y": 1345, "text": "22/30 flows have at least one gap. Most gaps are filter additions, not new commands.", "fontSize": 14, "strokeColor": "#ef4444" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/03-command-coverage.png
Normal file
BIN
docs/diagrams/03-command-coverage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
110
docs/diagrams/04-gap-priority-matrix.excalidraw
Normal file
110
docs/diagrams/04-gap-priority-matrix.excalidraw
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 300, "y": 20, "text": "Lore CLI Gap Priority Matrix", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 310, "y": 58, "text": "20 identified gaps plotted by impact vs effort", "fontSize": 16, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q1-zone", "x": 100, "y": 120, "width": 500, "height": 380,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q1-label", "x": 110, "y": 126, "text": "QUICK WINS", "fontSize": 18, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q2-zone", "x": 620, "y": 120, "width": 500, "height": 380,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q2-label", "x": 630, "y": 126, "text": "STRATEGIC", "fontSize": 18, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q3-zone", "x": 100, "y": 520, "width": 500, "height": 300,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q3-label", "x": 110, "y": 526, "text": "FILL-IN", "fontSize": 18, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q4-zone", "x": 620, "y": 520, "width": 500, "height": 300,
|
||||||
|
"backgroundColor": "#ffc9c9", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#ef4444", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q4-label", "x": 630, "y": 526, "text": "DEPRIORITIZE", "fontSize": 18, "strokeColor": "#c92a2a" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "y-axis-hi", "x": 30, "y": 130, "text": "HIGH\nIMPACT", "fontSize": 16, "strokeColor": "#495057", "textAlign": "center" },
|
||||||
|
{ "type": "text", "id": "y-axis-lo", "x": 30, "y": 550, "text": "LOW\nIMPACT", "fontSize": 16, "strokeColor": "#495057", "textAlign": "center" },
|
||||||
|
{ "type": "text", "id": "x-axis-lo", "x": 280, "y": 840, "text": "LOW EFFORT", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "x-axis-hi", "x": 800, "y": 840, "text": "HIGH EFFORT", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "y-arrow", "x": 85, "y": 810, "width": 0, "height": -680,
|
||||||
|
"points": [[0,0],[0,-680]], "endArrowhead": "arrow", "strokeColor": "#495057", "strokeWidth": 1 },
|
||||||
|
{ "type": "arrow", "id": "x-arrow", "x": 85, "y": 810, "width": 1050, "height": 0,
|
||||||
|
"points": [[0,0],[1050,0]], "endArrowhead": "arrow", "strokeColor": "#495057", "strokeWidth": 1 },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g5", "x": 120, "y": 160, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#5 @me alias", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g8", "x": 120, "y": 225, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#8 --state on search", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g9", "x": 120, "y": 290, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#9 mrs --milestone", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g10", "x": 120, "y": 355, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#10 --no-assignee", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g11", "x": 350, "y": 160, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#11 --updated-before", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g14", "x": 350, "y": 225, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#14 detail --fields", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g18", "x": 350, "y": 290, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#18 1y/12m duration", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g20", "x": 350, "y": 355, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#20 sort by due date", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g1", "x": 640, "y": 160, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#1 MR file changes", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g2", "x": 640, "y": 225, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#2 entity refs query", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g3", "x": 640, "y": 290, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#3 per-note search", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g4", "x": 880, "y": 160, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#4 entity timeline", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g6", "x": 880, "y": 225, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#6 activity feed", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g12", "x": 880, "y": 290, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#12 team workload", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g13", "x": 120, "y": 570, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#13 pagination/offset", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g15", "x": 120, "y": 635, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#15 group by project", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g19", "x": 120, "y": 700, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#19 participant filter", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g7", "x": 640, "y": 570, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#7 multi-path who", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g16", "x": 640, "y": 635, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#16 trend metrics", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g17", "x": 640, "y": 700, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#17 --for-issue on mrs", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "q1-count", "x": 180, "y": 430, "text": "8 gaps - lowest hanging fruit", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
{ "type": "text", "id": "q2-count", "x": 710, "y": 370, "text": "6 gaps - build deliberately", "fontSize": 14, "strokeColor": "#b45309" },
|
||||||
|
{ "type": "text", "id": "q3-count", "x": 160, "y": 770, "text": "3 gaps - fill as needed", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "q4-count", "x": 680, "y": 770, "text": "3 gaps - defer or rethink", "fontSize": 14, "strokeColor": "#c92a2a" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/04-gap-priority-matrix.png
Normal file
BIN
docs/diagrams/04-gap-priority-matrix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
184
docs/diagrams/05-data-flow-architecture.excalidraw
Normal file
184
docs/diagrams/05-data-flow-architecture.excalidraw
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 350, "y": 15, "text": "Lore Data Flow Architecture", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 280, "y": 53, "text": "Green = queryable via CLI | Red = stored but hidden | Gray = internal", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-gitlab", "x": 30, "y": 90, "width": 200, "height": 300,
|
||||||
|
"backgroundColor": "#e5dbff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#8b5cf6", "strokeWidth": 1, "opacity": 30 },
|
||||||
|
{ "type": "text", "id": "zone-gitlab-label", "x": 55, "y": 96, "text": "GitLab APIs", "fontSize": 16, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "rest-api", "x": 50, "y": 130, "width": 160, "height": 60,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "REST API\n(paginated)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "graphql-api", "x": 50, "y": 210, "width": 160, "height": 60,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "GraphQL API\n(adaptive pages)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ollama-api", "x": 50, "y": 310, "width": 160, "height": 60,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Ollama\n(embeddings)", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-ingest", "x": 270, "y": 90, "width": 180, "height": 300,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 30 },
|
||||||
|
{ "type": "text", "id": "zone-ingest-label", "x": 300, "y": 96, "text": "Ingestion", "fontSize": 16, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "ingest-issues", "x": 285, "y": 130, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Issue Sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ingest-mrs", "x": 285, "y": 195, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "MR Sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ingest-disc", "x": 285, "y": 260, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Discussion Sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ingest-events", "x": 285, "y": 325, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Event Sync", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-rest-issues", "x": 210, "y": 155, "width": 75, "height": 0,
|
||||||
|
"points": [[0,0],[75,0]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
{ "type": "arrow", "id": "a-rest-mrs", "x": 210, "y": 165, "width": 75, "height": 50,
|
||||||
|
"points": [[0,0],[75,50]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
{ "type": "arrow", "id": "a-graphql-issues", "x": 210, "y": 240, "width": 75, "height": -80,
|
||||||
|
"points": [[0,0],[75,-80]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-sqlite", "x": 490, "y": 90, "width": 400, "height": 650,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-sqlite-label", "x": 570, "y": 96, "text": "SQLite (WAL mode)", "fontSize": 16, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-queryable", "x": 500, "y": 120, "text": "Queryable Tables", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "t-projects", "x": 500, "y": 145, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "projects", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-issues", "x": 500, "y": 195, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues + assignees", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-mrs", "x": 500, "y": 245, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "merge_requests", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-discussions", "x": 500, "y": 295, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "discussions + notes", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-events", "x": 500, "y": 345, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "resource_*_events", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-docs", "x": 500, "y": 395, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "documents + FTS5", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-embed", "x": 500, "y": 445, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "embeddings (vec)", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-hidden", "x": 700, "y": 120, "text": "Hidden Tables", "fontSize": 14, "strokeColor": "#c92a2a" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "t-file-changes", "x": 695, "y": 145, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "mr_file_changes", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-entity-refs", "x": 695, "y": 195, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "entity_references", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-raw", "x": 695, "y": 245, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "raw_payloads", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-internal", "x": 700, "y": 310, "text": "Internal Only", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "t-sync", "x": 695, "y": 340, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96",
|
||||||
|
"label": { "text": "sync_runs + cursors", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-dirty", "x": 695, "y": 390, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96",
|
||||||
|
"label": { "text": "dirty_sources", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-locks", "x": 695, "y": 440, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96",
|
||||||
|
"label": { "text": "app_locks", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-ingest-tables", "x": 435, "y": 200, "width": 55, "height": 0,
|
||||||
|
"points": [[0,0],[55,0]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-cli", "x": 930, "y": 90, "width": 250, "height": 650,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "zone-cli-label", "x": 990, "y": 96, "text": "CLI Commands", "fontSize": 16, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "cmd-issues", "x": 950, "y": 130, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore issues", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-mrs", "x": 950, "y": 185, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore mrs", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-search", "x": 950, "y": 240, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore search", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-who", "x": 950, "y": 295, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore who", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-timeline", "x": 950, "y": 350, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore timeline", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-count", "x": 950, "y": 405, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore count", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-sync", "x": 950, "y": 460, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-status", "x": 950, "y": 515, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore status", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-issues-cmd", "x": 670, "y": 215, "width": 270, "height": -65,
|
||||||
|
"points": [[0,0],[270,-65]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-mrs-cmd", "x": 670, "y": 265, "width": 270, "height": -60,
|
||||||
|
"points": [[0,0],[270,-60]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-docs-cmd", "x": 670, "y": 415, "width": 270, "height": -155,
|
||||||
|
"points": [[0,0],[270,-155]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-embed-cmd", "x": 670, "y": 465, "width": 270, "height": -200,
|
||||||
|
"points": [[0,0],[270,-200]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-events-cmd", "x": 670, "y": 365, "width": 270, "height": 5,
|
||||||
|
"points": [[0,0],[270,5]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "hidden-note-1", "x": 695, "y": 498, "text": "mr_file_changes: populated by\nMR sync but NOT queryable.\nBlocks H4, A6, A10 flows.", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
{ "type": "text", "id": "hidden-note-2", "x": 695, "y": 568, "text": "entity_references: used by\ntimeline internally but NOT\nqueryable. Blocks A5, A11.", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-hidden-who", "x": 875, "y": 165, "width": 65, "height": 148,
|
||||||
|
"points": [[0,0],[65,148]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeWidth": 2,
|
||||||
|
"strokeStyle": "dashed" },
|
||||||
|
{ "type": "text", "id": "hidden-who-label", "x": 880, "y": 240, "text": "who uses\nDiffNotes,\nnot file\nchanges", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-hidden-timeline", "x": 875, "y": 215, "width": 65, "height": 155,
|
||||||
|
"points": [[0,0],[65,155]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeWidth": 2,
|
||||||
|
"strokeStyle": "dashed" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "cmd-missing-refs", "x": 950, "y": 580, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed",
|
||||||
|
"label": { "text": "lore refs (missing)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-missing-files", "x": 950, "y": 635, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed",
|
||||||
|
"label": { "text": "lore files (missing)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-missing-activity", "x": 950, "y": 690, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed",
|
||||||
|
"label": { "text": "lore activity (missing)", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "legend-title", "x": 30, "y": 430, "text": "Legend", "fontSize": 16 },
|
||||||
|
{ "type": "rectangle", "id": "leg-green", "x": 30, "y": 460, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-green-t", "x": 60, "y": 462, "text": "Queryable via CLI", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-red", "x": 30, "y": 490, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444" },
|
||||||
|
{ "type": "text", "id": "leg-red-t", "x": 60, "y": 492, "text": "Stored but hidden", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-gray", "x": 30, "y": 520, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96" },
|
||||||
|
{ "type": "text", "id": "leg-gray-t", "x": 60, "y": 522, "text": "Internal bookkeeping", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-dashed", "x": 30, "y": 550, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "text", "id": "leg-dashed-t", "x": 60, "y": 552, "text": "Missing command", "fontSize": 14 }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/05-data-flow-architecture.png
Normal file
BIN
docs/diagrams/05-data-flow-architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
179
docs/performance-audit-2026-02-12.md
Normal file
179
docs/performance-audit-2026-02-12.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Deep Performance Audit Report
|
||||||
|
|
||||||
|
**Date:** 2026-02-12
|
||||||
|
**Branch:** `perf-audit` (e9bacc94)
|
||||||
|
**Parent:** `039ab1c2` (master, v0.6.1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
1. **Baseline** — measured p50/p95 latency for all major commands with warm cache
|
||||||
|
2. **Profile** — used macOS `sample` profiler and `EXPLAIN QUERY PLAN` to identify hotspots
|
||||||
|
3. **Golden output** — captured exact numeric outputs before changes as equivalence oracle
|
||||||
|
4. **One lever per change** — each optimization isolated and independently benchmarked
|
||||||
|
5. **Revert threshold** — any optimization <1.1x speedup reverted per audit rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Measurements (warm cache, release build)
|
||||||
|
|
||||||
|
| Command | Latency | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| `who --path src/core/db.rs` (expert) | 2200ms | **Hotspot** |
|
||||||
|
| `who --active` | 83-93ms | Acceptable |
|
||||||
|
| `who workload` | 22ms | Fast |
|
||||||
|
| `stats` | 107-112ms | **Hotspot** |
|
||||||
|
| `search "authentication"` | 1030ms | **Hotspot** (library-level) |
|
||||||
|
| `list issues -n 50` | ~40ms | Fast |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimization 1: INDEXED BY for DiffNote Queries
|
||||||
|
|
||||||
|
**Target:** `src/cli/commands/who.rs` — expert and reviews query paths
|
||||||
|
|
||||||
|
**Problem:** SQLite query planner chose `idx_notes_system` (38% selectivity, 106K rows) over `idx_notes_diffnote_path_created` (9.3% selectivity, 26K rows) for path-filtered DiffNote queries. The partial index `WHERE noteable_type = 'MergeRequest' AND type = 'DiffNote'` is far more selective but the planner's cost model didn't pick it.
|
||||||
|
|
||||||
|
**Change:** Added `INDEXED BY idx_notes_diffnote_path_created` to all 8 SQL queries across `query_expert`, `query_expert_details`, `query_reviews`, `build_path_query` (probes 1 & 2), and `suffix_probe`.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
| Query | Before | After | Speedup |
|
||||||
|
|-------|--------|-------|---------|
|
||||||
|
| expert (specific path) | 2200ms | 56-58ms | **38x** |
|
||||||
|
| expert (broad path) | 2200ms | 83ms | **26x** |
|
||||||
|
| reviews | 1800ms | 24ms | **75x** |
|
||||||
|
|
||||||
|
**Isomorphism proof:** `INDEXED BY` only changes which index the planner uses, not the query semantics. Same rows matched, same ordering, same output. Verified by golden output comparison across 5+ runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimization 2: Conditional Aggregates in Stats
|
||||||
|
|
||||||
|
**Target:** `src/cli/commands/stats.rs`
|
||||||
|
|
||||||
|
**Problem:** 12+ sequential `COUNT(*)` queries each requiring a full table scan of `documents` (61K rows). Each scan touched the same pages but couldn't share work.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Documents: 5 sequential COUNTs -> 1 query with `SUM(CASE WHEN ... THEN 1 END)`
|
||||||
|
- FTS count: `SELECT COUNT(*) FROM documents_fts` (virtual table, slow) -> `SELECT COUNT(*) FROM documents_fts_docsize` (shadow B-tree table, 19x faster)
|
||||||
|
- Embeddings: 2 queries -> 1 with `COUNT(DISTINCT document_id), COUNT(*)`
|
||||||
|
- Dirty sources: 2 queries -> 1 with conditional aggregates
|
||||||
|
- Pending fetches: 2 queries -> 1 each (discussions, dependents)
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
| Metric | Before | After | Speedup |
|
||||||
|
|--------|--------|-------|---------|
|
||||||
|
| Warm median | 112ms | 66ms | **1.70x** |
|
||||||
|
| Cold | 1220ms | ~700ms | ~1.7x |
|
||||||
|
|
||||||
|
**Golden output verified:**
|
||||||
|
|
||||||
|
```
|
||||||
|
total:61652, issues:8241, mrs:10018, discussions:43393, truncated:63
|
||||||
|
fts:61652, embedded:61652, chunks:88161
|
||||||
|
```
|
||||||
|
|
||||||
|
All values match exactly across before/after runs.
|
||||||
|
|
||||||
|
**Isomorphism proof:** `SUM(CASE WHEN x THEN 1 END)` is algebraically identical to `COUNT(*) WHERE x`. The FTS5 shadow table `documents_fts_docsize` has exactly one row per FTS document by SQLite specification, so `COUNT(*)` on it equals the virtual table count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Investigation: Two-Phase FTS Search (REVERTED)
|
||||||
|
|
||||||
|
**Target:** `src/search/fts.rs`, `src/cli/commands/search.rs`
|
||||||
|
|
||||||
|
**Hypothesis:** FTS5 `snippet()` generation is expensive. Splitting search into Phase 1 (score-only MATCH+bm25) and Phase 2 (snippet for filtered results only) should reduce work.
|
||||||
|
|
||||||
|
**Implementation:** Created `fetch_fts_snippets()` that retrieves snippets only for post-filter document IDs via `json_each()` join.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
| Metric | Before | After | Improvement |
|
||||||
|
|--------|--------|-------|-------------|
|
||||||
|
| search (limit 20) | 1030ms | 995ms | 3.5% |
|
||||||
|
|
||||||
|
**Decision:** Reverted. Per audit rules, <1.1x speedup does not justify added code complexity.
|
||||||
|
|
||||||
|
**Root cause:** The bottleneck is not snippet generation but `MATCH` + `bm25()` scoring itself. Profiling showed `strspn` (FTS5 tokenizer) and `memmove` as the top CPU consumers. The same query runs in 30ms on system sqlite3 but 1030ms in rusqlite's bundled SQLite — a ~125x gap despite both being SQLite 3.51.x compiled at -O3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Library-Level Finding: Bundled SQLite FTS5 Performance
|
||||||
|
|
||||||
|
**Observation:** FTS5 MATCH+bm25 queries are ~125x slower in rusqlite's bundled SQLite vs system sqlite3.
|
||||||
|
|
||||||
|
| Environment | Query Time | Notes |
|
||||||
|
|-------------|-----------|-------|
|
||||||
|
| System sqlite3 (macOS) | 30ms (with snippet), 8ms (without) | Same .db file |
|
||||||
|
| rusqlite bundled | 1030ms | `features = ["bundled"]`, OPT_LEVEL=3 |
|
||||||
|
|
||||||
|
**Profiler data (macOS `sample`):**
|
||||||
|
- Top hotspot: `strspn` in FTS5 tokenizer
|
||||||
|
- Secondary: `memmove` in FTS5 internals
|
||||||
|
- Scaling: ~5ms per result (limit 5 = 497ms, limit 20 = 995ms)
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
- Bundled SQLite compiled without platform-specific optimizations (SIMD, etc.)
|
||||||
|
- Different memory allocator behavior
|
||||||
|
- Missing compile-time tuning flags
|
||||||
|
|
||||||
|
**Recommendation for future:** Investigate switching from `features = ["bundled"]` to system SQLite linkage, or audit the bundled compile flags in the `libsqlite3-sys` build script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exploration Agent Findings (Informational)
|
||||||
|
|
||||||
|
Four parallel exploration agents surveyed the entire codebase. Key findings beyond what was already addressed:
|
||||||
|
|
||||||
|
### Ingestion Pipeline
|
||||||
|
- Serial DB writes in async context (acceptable — rusqlite is synchronous)
|
||||||
|
- Label ingestion uses individual inserts (potential batch optimization, low priority)
|
||||||
|
|
||||||
|
### CLI / GitLab Client
|
||||||
|
- GraphQL client recreated per call (`client.rs:98-100`) — caches connection pool, minor
|
||||||
|
- Double JSON deserialization in GraphQL responses — medium priority
|
||||||
|
- N+1 subqueries in `list` command (`list.rs:408-423`) — 4 correlated subqueries per row
|
||||||
|
|
||||||
|
### Search / Embedding
|
||||||
|
- No N+1 patterns, no O(n^2) algorithms
|
||||||
|
- Chunking is O(n) single-pass with proper UTF-8 safety
|
||||||
|
- Ollama concurrency model is sound (parallel HTTP, serial DB writes)
|
||||||
|
|
||||||
|
### Database / Documents
|
||||||
|
- O(n^2) prefix sum in `truncation.rs` — low traffic path
|
||||||
|
- String allocation patterns in extractors — micro-optimization territory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opportunity Matrix
|
||||||
|
|
||||||
|
| Candidate | Impact | Confidence | Effort | Score | Status |
|
||||||
|
|-----------|--------|------------|--------|-------|--------|
|
||||||
|
| INDEXED BY for DiffNote | Very High | High | Low | **9.0** | Shipped |
|
||||||
|
| Stats conditional aggregates | Medium | High | Low | **7.0** | Shipped |
|
||||||
|
| Bundled SQLite FTS5 | Very High | Medium | High | 5.0 | Documented |
|
||||||
|
| List N+1 subqueries | Medium | Medium | Medium | 4.0 | Backlog |
|
||||||
|
| GraphQL double deser | Low | Medium | Low | 3.5 | Backlog |
|
||||||
|
| Truncation O(n^2) | Low | High | Low | 3.0 | Backlog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/cli/commands/who.rs` | INDEXED BY hints on 8 SQL queries |
|
||||||
|
| `src/cli/commands/stats.rs` | Conditional aggregates, FTS5 shadow table, merged queries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- All 603 tests pass
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` clean
|
||||||
|
- `cargo fmt --check` clean
|
||||||
|
- Golden output verified for both optimizations
|
||||||
202
docs/plan-expose-discussion-ids.feedback-1.md
Normal file
202
docs/plan-expose-discussion-ids.feedback-1.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
No `## Rejected Recommendations` section appears in the plan you pasted, so the revisions below are all net-new.
|
||||||
|
|
||||||
|
1. **Add an explicit “Bridge Contract” and fix scope inconsistency**
|
||||||
|
Analysis: The plan says “Three changes” but defines four. More importantly, identifier requirements are scattered. A single contract section prevents drift and makes every new read surface prove it can drive a write call.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@
|
||||||
|
-**Scope**: Three changes, delivered in order:
|
||||||
|
+**Scope**: Four workstreams, delivered in order:
|
||||||
|
1. Add `gitlab_discussion_id` to notes output
|
||||||
|
2. Add `gitlab_discussion_id` to show command discussion groups
|
||||||
|
3. Add a standalone `discussions` list command
|
||||||
|
4. Fix robot-docs to list actual field names instead of opaque type references
|
||||||
|
+
|
||||||
|
+## Bridge Contract (Cross-Cutting)
|
||||||
|
+Every read payload that surfaces notes/discussions MUST include:
|
||||||
|
+- `project_path`
|
||||||
|
+- `noteable_type`
|
||||||
|
+- `parent_iid`
|
||||||
|
+- `gitlab_discussion_id`
|
||||||
|
+- `gitlab_note_id` (when note-level data is returned)
|
||||||
|
+This contract is required so agents can deterministically construct `glab api` write calls.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Normalize identifier naming now (break ambiguous names)**
|
||||||
|
Analysis: Current `id`/`gitlab_id` naming is ambiguous in mixed payloads. Rename to explicit `note_id` and `gitlab_note_id` now (you explicitly don’t care about backward compatibility). This reduces automation mistakes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 1b. Add field to `NoteListRow`
|
||||||
|
-pub struct NoteListRow {
|
||||||
|
- pub id: i64,
|
||||||
|
- pub gitlab_id: i64,
|
||||||
|
+pub struct NoteListRow {
|
||||||
|
+ pub note_id: i64, // local DB id
|
||||||
|
+ pub gitlab_note_id: i64, // GitLab note id
|
||||||
|
@@
|
||||||
|
@@ 1c. Add field to `NoteListRowJson`
|
||||||
|
-pub struct NoteListRowJson {
|
||||||
|
- pub id: i64,
|
||||||
|
- pub gitlab_id: i64,
|
||||||
|
+pub struct NoteListRowJson {
|
||||||
|
+ pub note_id: i64,
|
||||||
|
+ pub gitlab_note_id: i64,
|
||||||
|
@@
|
||||||
|
-#### 2f. Add `gitlab_note_id` to note detail structs in show
|
||||||
|
-While we're here, add `gitlab_id` to `NoteDetail`, `MrNoteDetail`, and their JSON
|
||||||
|
+#### 2f. Add `gitlab_note_id` to note detail structs in show
|
||||||
|
+While we're here, add `gitlab_note_id` to `NoteDetail`, `MrNoteDetail`, and their JSON
|
||||||
|
counterparts.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Stop positional column indexing for these changes**
|
||||||
|
Analysis: In `list.rs`, row extraction is positional (`row.get(18)`, etc.). Adding fields is fragile and easy to break silently. Use named aliases and named lookup for robustness.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 1a/1b SQL + query_map
|
||||||
|
- p.path_with_namespace AS project_path
|
||||||
|
+ p.path_with_namespace AS project_path,
|
||||||
|
+ d.gitlab_discussion_id AS gitlab_discussion_id
|
||||||
|
@@
|
||||||
|
- project_path: row.get(18)?,
|
||||||
|
- gitlab_discussion_id: row.get(19)?,
|
||||||
|
+ project_path: row.get("project_path")?,
|
||||||
|
+ gitlab_discussion_id: row.get("gitlab_discussion_id")?,
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Redesign `discussions` query to avoid correlated subquery fanout**
|
||||||
|
Analysis: Proposed query uses many correlated subqueries per row. That’s acceptable for tiny MR-scoped sets, but degrades for project-wide scans. Use a base CTE + one rollup pass over notes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 3c. SQL Query
|
||||||
|
-SELECT
|
||||||
|
- d.id,
|
||||||
|
- ...
|
||||||
|
- (SELECT COUNT(*) FROM notes n2 WHERE n2.discussion_id = d.id AND n2.is_system = 0) AS note_count,
|
||||||
|
- (SELECT n3.author_username FROM notes n3 WHERE n3.discussion_id = d.id ORDER BY n3.position LIMIT 1) AS first_author,
|
||||||
|
- ...
|
||||||
|
-FROM discussions d
|
||||||
|
+WITH base AS (
|
||||||
|
+ SELECT d.id, d.gitlab_discussion_id, d.noteable_type, d.project_id, d.issue_id, d.merge_request_id,
|
||||||
|
+ d.individual_note, d.first_note_at, d.last_note_at, d.resolvable, d.resolved
|
||||||
|
+ FROM discussions d
|
||||||
|
+ {where_sql}
|
||||||
|
+),
|
||||||
|
+note_rollup AS (
|
||||||
|
+ SELECT n.discussion_id,
|
||||||
|
+ COUNT(*) FILTER (WHERE n.is_system = 0) AS user_note_count,
|
||||||
|
+ COUNT(*) AS total_note_count,
|
||||||
|
+ MIN(CASE WHEN n.is_system = 0 THEN n.position END) AS first_user_pos
|
||||||
|
+ FROM notes n
|
||||||
|
+ JOIN base b ON b.id = n.discussion_id
|
||||||
|
+ GROUP BY n.discussion_id
|
||||||
|
+)
|
||||||
|
+SELECT ...
|
||||||
|
+FROM base b
|
||||||
|
+LEFT JOIN note_rollup r ON r.discussion_id = b.id
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add explicit index work for new access patterns**
|
||||||
|
Analysis: Existing indexes are good but not ideal for new list patterns (`project + last_note`, note position ordering inside discussion). Add migration entries to keep latency stable.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## 3. Add Standalone `discussions` List Command
|
||||||
|
+#### 3h. Add migration for discussion-list performance
|
||||||
|
+**File**: `migrations/027_discussions_list_indexes.sql`
|
||||||
|
+```sql
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_discussions_project_last_note
|
||||||
|
+ ON discussions(project_id, last_note_at DESC, id DESC);
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_discussions_project_first_note
|
||||||
|
+ ON discussions(project_id, first_note_at DESC, id DESC);
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_discussion_position
|
||||||
|
+ ON notes(discussion_id, position);
|
||||||
|
+```
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add keyset pagination (critical for agent workflows)**
|
||||||
|
Analysis: `--limit` alone is not enough for automation over large datasets. Add cursor-based pagination with deterministic sort keys and `next_cursor` in JSON.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 3a. CLI Args
|
||||||
|
+ /// Keyset cursor from previous response
|
||||||
|
+ #[arg(long, help_heading = "Output")]
|
||||||
|
+ pub cursor: Option<String>,
|
||||||
|
@@
|
||||||
|
@@ Response Schema
|
||||||
|
- "total_count": 15,
|
||||||
|
- "showing": 15
|
||||||
|
+ "total_count": 15,
|
||||||
|
+ "showing": 15,
|
||||||
|
+ "next_cursor": "eyJsYXN0X25vdGVfYXQiOjE3MDAwMDAwMDAwMDAsImlkIjoxMjN9"
|
||||||
|
@@
|
||||||
|
@@ Validation Criteria
|
||||||
|
+7. `lore -J discussions ... --cursor <token>` returns the next stable page without duplicates/skips
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Fix semantic ambiguities in discussion summary fields**
|
||||||
|
Analysis: `note_count` is ambiguous, and `first_author` can accidentally be a system note author. Make fields explicit and consistent with non-system default behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Response Schema
|
||||||
|
- "note_count": 3,
|
||||||
|
- "first_author": "elovegrove",
|
||||||
|
+ "user_note_count": 3,
|
||||||
|
+ "total_note_count": 4,
|
||||||
|
+ "first_user_author": "elovegrove",
|
||||||
|
@@
|
||||||
|
@@ 3d. Filters struct / path behavior
|
||||||
|
-- `path` → `EXISTS (SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.position_new_path LIKE ?)`
|
||||||
|
+- `path` → match on BOTH `position_new_path` and `position_old_path` (exact/prefix)
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Enrich show outputs with actionable thread metadata**
|
||||||
|
Analysis: Adding only discussion id helps, but agents still need thread state and note ids to pick targets correctly. Add `resolvable`, `resolved`, `last_note_at_iso`, and `gitlab_note_id` in show discussion payloads.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 2a/2b show discussion structs
|
||||||
|
pub struct DiscussionDetailJson {
|
||||||
|
pub gitlab_discussion_id: String,
|
||||||
|
+ pub resolvable: bool,
|
||||||
|
+ pub resolved: bool,
|
||||||
|
+ pub last_note_at_iso: String,
|
||||||
|
pub notes: Vec<NoteDetailJson>,
|
||||||
|
@@
|
||||||
|
pub struct NoteDetailJson {
|
||||||
|
+ pub gitlab_note_id: i64,
|
||||||
|
pub author_username: String,
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Harden robot-docs against schema drift with tests**
|
||||||
|
Analysis: Static JSON in `main.rs` will drift again. Add a lightweight contract test that asserts docs include required fields for `notes`, `discussions`, and show payloads.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 4. Fix Robot-Docs Response Schemas
|
||||||
|
+#### 4f. Add robot-docs contract tests
|
||||||
|
+**File**: `src/main.rs` (or dedicated test module)
|
||||||
|
+- Assert `robot-docs` contains `gitlab_discussion_id` and `gitlab_note_id` in:
|
||||||
|
+ - `notes.response_schema`
|
||||||
|
+ - `issues.response_schema.show`
|
||||||
|
+ - `mrs.response_schema.show`
|
||||||
|
+ - `discussions.response_schema`
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Adjust delivery order to reduce rework and include missing CSV path**
|
||||||
|
Analysis: In your sample `handle_discussions`, `csv` is declared in args but not handled. Also, robot-docs should land after all payload changes. Sequence should minimize churn.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Delivery Order
|
||||||
|
-3. **Change 4** (robot-docs) — depends on 1 and 2 being done so schemas are accurate.
|
||||||
|
-4. **Change 3** (discussions command) — largest change, depends on 1 for design consistency.
|
||||||
|
+3. **Change 3** (discussions command + indexes + pagination) — largest change.
|
||||||
|
+4. **Change 4** (robot-docs + contract tests) — last, after payloads are final.
|
||||||
|
@@ 3e. Handler wiring
|
||||||
|
- match format {
|
||||||
|
+ match format {
|
||||||
|
"json" => ...
|
||||||
|
"jsonl" => ...
|
||||||
|
+ "csv" => print_list_discussions_csv(&result),
|
||||||
|
_ => ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated revised plan markdown with these edits applied so you can drop it in directly.
|
||||||
162
docs/plan-expose-discussion-ids.feedback-2.md
Normal file
162
docs/plan-expose-discussion-ids.feedback-2.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
Best non-rejected upgrades I’d make to this plan are below. They focus on reducing schema drift, making robot output safer to consume, and improving performance behavior at scale.
|
||||||
|
|
||||||
|
1. Add a shared contract model and field constants first (before workstreams 1-4)
|
||||||
|
Rationale: Right now each command has its own structs and ad-hoc mapping. That is exactly how drift happens. A single contract definition reused by `notes`, `show`, `discussions`, and robot-docs gives compile-time coupling between output payloads and docs. It also makes future fields cheaper and safer to add.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Scope: Four workstreams, delivered in order:
|
||||||
|
-1. Add `gitlab_discussion_id` to notes output
|
||||||
|
-2. Add `gitlab_discussion_id` to show command discussion groups
|
||||||
|
-3. Add a standalone `discussions` list command
|
||||||
|
-4. Fix robot-docs to list actual field names instead of opaque type references
|
||||||
|
+0. Introduce shared Bridge Contract model/constants used by notes/show/discussions/robot-docs
|
||||||
|
+1. Add `gitlab_discussion_id` to notes output
|
||||||
|
+2. Add `gitlab_discussion_id` to show command discussion groups
|
||||||
|
+3. Add a standalone `discussions` list command
|
||||||
|
+4. Fix robot-docs to list actual field names instead of opaque type references
|
||||||
|
|
||||||
|
+## 0. Shared Contract Model (Cross-Cutting)
|
||||||
|
+Define canonical required-field constants and shared mapping helpers, then consume them in:
|
||||||
|
+- `src/cli/commands/list.rs`
|
||||||
|
+- `src/cli/commands/show.rs`
|
||||||
|
+- `src/cli/robot.rs`
|
||||||
|
+- `src/main.rs` robot-docs builder
|
||||||
|
+This removes duplicated field-name strings and prevents docs/output mismatch.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Make bridge fields “non-droppable” in robot mode
|
||||||
|
Rationale: The current plan adds fields, but `--fields` can still remove them. That breaks the core read/write bridge contract in exactly the workflows this change is trying to fix. In robot mode, contract fields should always be force-included.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Bridge Contract (Cross-Cutting)
|
||||||
|
Every read payload that surfaces notes or discussions **MUST** include:
|
||||||
|
- `project_path`
|
||||||
|
- `noteable_type`
|
||||||
|
- `parent_iid`
|
||||||
|
- `gitlab_discussion_id`
|
||||||
|
- `gitlab_note_id` (when note-level data is returned — i.e., in notes list and show detail)
|
||||||
|
|
||||||
|
+### Field Filtering Guardrail
|
||||||
|
+In robot mode, `filter_fields` must force-include Bridge Contract fields even when users pass a narrower `--fields` list.
|
||||||
|
+Human/table mode keeps existing behavior.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Replace correlated subqueries in `discussions` rollup with a single-pass window/aggregate pattern
|
||||||
|
Rationale: Your CTE is better than naive fanout, but it still uses multiple correlated sub-selects per discussion for first author/body/path. At 200K+ discussions this can regress badly depending on cache/index state. A window-ranked `notes` CTE with grouped aggregates is usually faster and more predictable in SQLite.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ #### 3c. SQL Query
|
||||||
|
-Core query uses a CTE + rollup to avoid correlated subquery fanout on larger result sets:
|
||||||
|
+Core query uses a CTE + ranked-notes rollup (window function) to avoid per-row correlated subqueries:
|
||||||
|
|
||||||
|
-WITH filtered_discussions AS (...),
|
||||||
|
-note_rollup AS (
|
||||||
|
- SELECT
|
||||||
|
- n.discussion_id,
|
||||||
|
- SUM(...) AS note_count,
|
||||||
|
- (SELECT ... LIMIT 1) AS first_author,
|
||||||
|
- (SELECT ... LIMIT 1) AS first_note_body,
|
||||||
|
- (SELECT ... LIMIT 1) AS position_new_path,
|
||||||
|
- (SELECT ... LIMIT 1) AS position_new_line
|
||||||
|
- FROM notes n
|
||||||
|
- ...
|
||||||
|
-)
|
||||||
|
+WITH filtered_discussions AS (...),
|
||||||
|
+ranked_notes AS (
|
||||||
|
+ SELECT
|
||||||
|
+ n.*,
|
||||||
|
+ ROW_NUMBER() OVER (PARTITION BY n.discussion_id ORDER BY n.position, n.id) AS rn
|
||||||
|
+ FROM notes n
|
||||||
|
+ WHERE n.discussion_id IN (SELECT id FROM filtered_discussions)
|
||||||
|
+),
|
||||||
|
+note_rollup AS (
|
||||||
|
+ SELECT
|
||||||
|
+ discussion_id,
|
||||||
|
+ SUM(CASE WHEN is_system = 0 THEN 1 ELSE 0 END) AS note_count,
|
||||||
|
+ MAX(CASE WHEN rn = 1 AND is_system = 0 THEN author_username END) AS first_author,
|
||||||
|
+ MAX(CASE WHEN rn = 1 AND is_system = 0 THEN body END) AS first_note_body,
|
||||||
|
+ MAX(CASE WHEN position_new_path IS NOT NULL THEN position_new_path END) AS position_new_path,
|
||||||
|
+ MAX(CASE WHEN position_new_line IS NOT NULL THEN position_new_line END) AS position_new_line
|
||||||
|
+ FROM ranked_notes
|
||||||
|
+ GROUP BY discussion_id
|
||||||
|
+)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add direct GitLab ID filters for deterministic bridging
|
||||||
|
Rationale: Bridge workflows often start from one known ID. You already have `gitlab_note_id` in notes filters, but discussion filtering still looks internal-ID-centric. Add explicit GitLab-ID filters so agents do not need extra translation calls.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ #### 3a. CLI Args
|
||||||
|
pub struct DiscussionsArgs {
|
||||||
|
+ /// Filter by GitLab discussion ID
|
||||||
|
+ #[arg(long, help_heading = "Filters")]
|
||||||
|
+ pub gitlab_discussion_id: Option<String>,
|
||||||
|
@@
|
||||||
|
|
||||||
|
@@ #### 3d. Filters struct
|
||||||
|
pub struct DiscussionListFilters {
|
||||||
|
+ pub gitlab_discussion_id: Option<String>,
|
||||||
|
@@
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## 1. Add `gitlab_discussion_id` to Notes Output
|
||||||
|
+#### 1g. Add `--gitlab-discussion-id` filter to notes
|
||||||
|
+Allow filtering notes directly by GitLab thread ID (not only internal discussion ID).
|
||||||
|
+This enables one-hop note retrieval from external references.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Add optional note expansion to `discussions` for fewer round-trips
|
||||||
|
Rationale: Today the agent flow is often `discussions -> show`. Optional embedded notes (`--include-notes N`) gives a fast path for “list unresolved threads with latest context” without forcing full show payloads.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ### Design
|
||||||
|
lore -J discussions --for-mr 99 --resolution unresolved
|
||||||
|
+lore -J discussions --for-mr 99 --resolution unresolved --include-notes 2
|
||||||
|
|
||||||
|
@@ #### 3a. CLI Args
|
||||||
|
+ /// Include up to N latest notes per discussion (0 = none)
|
||||||
|
+ #[arg(long, default_value = "0", help_heading = "Output")]
|
||||||
|
+ pub include_notes: usize,
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Upgrade robot-docs from string blobs to structured schema + explicit contract block
|
||||||
|
Rationale: `contains("gitlab_discussion_id")` tests on schema strings are brittle. A structured schema object gives machine-checked docs and reliable test assertions. Add a contract section for agent consumers.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## 4. Fix Robot-Docs Response Schemas
|
||||||
|
-#### 4a. Notes response_schema
|
||||||
|
-Replace stringly-typed schema snippets...
|
||||||
|
+#### 4a. Notes response_schema (structured)
|
||||||
|
+Represent response fields as JSON objects (field -> type/nullable), not freeform strings.
|
||||||
|
|
||||||
|
+#### 4g. Add `bridge_contract` section in robot-docs
|
||||||
|
+Publish canonical required fields per entity:
|
||||||
|
+- notes
|
||||||
|
+- discussions
|
||||||
|
+- show.discussions
|
||||||
|
+- show.notes
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Strengthen validation: add CLI-level contract tests and perf guardrails
|
||||||
|
Rationale: Most current tests are unit-level struct/query checks. Add end-to-end JSON contract tests via command handlers, plus a benchmark-style regression test (ignored by default) so performance work stays intentional.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Validation Criteria
|
||||||
|
8. Bridge Contract fields (...) are present in every applicable read payload
|
||||||
|
+9. Contract fields remain present even with `--fields` in robot mode
|
||||||
|
+10. `discussions` query meets performance guardrail on representative fixture (documented threshold)
|
||||||
|
|
||||||
|
@@ ### Tests
|
||||||
|
+#### Test: robot-mode fields cannot drop bridge contract keys
|
||||||
|
+Run notes/discussions JSON output through `filter_fields` path and assert required keys remain.
|
||||||
|
+
|
||||||
|
+#### Test: CLI contract integration
|
||||||
|
+Invoke command handlers for `notes`, `discussions`, `mrs <iid>`, parse JSON, assert required keys and types.
|
||||||
|
+
|
||||||
|
+#### Test (ignored): large-fixture performance regression
|
||||||
|
+Generate representative fixture and assert `query_discussions` stays under target elapsed time.
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can now produce a full “v2 plan” document that applies these diffs end-to-end (including revised delivery order and complete updated sections).
|
||||||
147
docs/plan-expose-discussion-ids.feedback-3.md
Normal file
147
docs/plan-expose-discussion-ids.feedback-3.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
1. **Make `gitlab_note_id` explicit in all note-level payloads without breaking existing consumers**
|
||||||
|
Rationale: Your Bridge Contract already requires `gitlab_note_id`, but current plan keeps `gitlab_id` only in `notes` list while adding `gitlab_note_id` only in `show`. That forces agents to special-case commands. Add `gitlab_note_id` as an alias field everywhere note-level data appears, while keeping `gitlab_id` for compatibility.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Bridge Contract (Cross-Cutting)
|
||||||
|
-Every read payload that surfaces notes or discussions MUST include:
|
||||||
|
+Every read payload that surfaces notes or discussions MUST include:
|
||||||
|
- project_path
|
||||||
|
- noteable_type
|
||||||
|
- parent_iid
|
||||||
|
- gitlab_discussion_id
|
||||||
|
- gitlab_note_id (when note-level data is returned — i.e., in notes list and show detail)
|
||||||
|
+ - Back-compat rule: note payloads may continue exposing `gitlab_id`, but MUST also expose `gitlab_note_id` with the same value.
|
||||||
|
|
||||||
|
@@ 1. Add `gitlab_discussion_id` to Notes Output
|
||||||
|
-#### 1c. Add field to `NoteListRowJson`
|
||||||
|
+#### 1c. Add fields to `NoteListRowJson`
|
||||||
|
+Add `gitlab_note_id` alias in addition to existing `gitlab_id` (no rename, no breakage).
|
||||||
|
|
||||||
|
@@ 1f. Update `--fields minimal` preset
|
||||||
|
-"notes" => ["id", "author_username", "body", "created_at_iso", "gitlab_discussion_id"]
|
||||||
|
+"notes" => ["id", "gitlab_note_id", "author_username", "body", "created_at_iso", "gitlab_discussion_id"]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Avoid duplicate flag semantics for discussion filtering**
|
||||||
|
Rationale: `notes` already has `--discussion-id` and it already maps to `d.gitlab_discussion_id`. Adding a second independent flag/field (`--gitlab-discussion-id`) increases complexity and precedence bugs. Keep one backing filter field and make the new flag an alias.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 1g. Add `--gitlab-discussion-id` filter to notes
|
||||||
|
-Allow filtering notes directly by GitLab discussion thread ID...
|
||||||
|
+Normalize discussion ID flags:
|
||||||
|
+- Keep one backing filter field (`discussion_id`)
|
||||||
|
+- Support both `--discussion-id` (existing) and `--gitlab-discussion-id` (alias)
|
||||||
|
+- If both are provided, clap should reject as duplicate/alias conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add ambiguity guardrails for cross-project discussion IDs**
|
||||||
|
Rationale: `gitlab_discussion_id` is unique per project, not globally. Filtering by discussion ID without project can return multiple rows across repos, which breaks deterministic write bridging. Fail fast with an `Ambiguous` error and actionable fix (`--project`).
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Bridge Contract (Cross-Cutting)
|
||||||
|
+### Ambiguity Guardrail
|
||||||
|
+When filtering by `gitlab_discussion_id` without `--project`, if multiple projects match:
|
||||||
|
+- return `Ambiguous` error
|
||||||
|
+- include matching project paths in message
|
||||||
|
+- suggest retry with `--project <path>`
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Replace `--include-notes` N+1 retrieval with one batched top-N query**
|
||||||
|
Rationale: The current plan’s per-discussion follow-up query scales poorly and creates latency spikes. Use a single window-function query over selected discussion IDs and group rows in Rust. This is both faster and more predictable.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 3c-ii. Note expansion query (--include-notes)
|
||||||
|
-When `include_notes > 0`, after the main discussion query, run a follow-up query per discussion...
|
||||||
|
+When `include_notes > 0`, run one batched query:
|
||||||
|
+WITH ranked_notes AS (
|
||||||
|
+ SELECT
|
||||||
|
+ n.*,
|
||||||
|
+ d.gitlab_discussion_id,
|
||||||
|
+ ROW_NUMBER() OVER (
|
||||||
|
+ PARTITION BY n.discussion_id
|
||||||
|
+ ORDER BY n.created_at DESC, n.id DESC
|
||||||
|
+ ) AS rn
|
||||||
|
+ FROM notes n
|
||||||
|
+ JOIN discussions d ON d.id = n.discussion_id
|
||||||
|
+ WHERE n.discussion_id IN ( ...selected discussion ids... )
|
||||||
|
+)
|
||||||
|
+SELECT ... FROM ranked_notes WHERE rn <= ?
|
||||||
|
+ORDER BY discussion_id, rn;
|
||||||
|
+
|
||||||
|
+Group by `discussion_id` in Rust and attach notes arrays without per-thread round-trips.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add hard output guardrails and explicit truncation metadata**
|
||||||
|
Rationale: `--limit` and `--include-notes` are unbounded today. For robot workflows this can accidentally generate huge payloads. Cap values and surface effective limits plus truncation state in `meta`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 3a. CLI Args
|
||||||
|
- pub limit: usize,
|
||||||
|
+ pub limit: usize, // clamp to max (e.g., 500)
|
||||||
|
|
||||||
|
- pub include_notes: usize,
|
||||||
|
+ pub include_notes: usize, // clamp to max (e.g., 20)
|
||||||
|
|
||||||
|
@@ Response Schema
|
||||||
|
- "meta": { "elapsed_ms": 12 }
|
||||||
|
+ "meta": {
|
||||||
|
+ "elapsed_ms": 12,
|
||||||
|
+ "effective_limit": 50,
|
||||||
|
+ "effective_include_notes": 2,
|
||||||
|
+ "has_more": true
|
||||||
|
+ }
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Strengthen deterministic ordering and null handling**
|
||||||
|
Rationale: `first_note_at`, `last_note_at`, and note `position` can be null/incomplete during partial sync states. Add null-safe ordering to avoid unstable output and flaky automation.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 2c. Update queries to SELECT new fields
|
||||||
|
-... ORDER BY first_note_at
|
||||||
|
+... ORDER BY COALESCE(first_note_at, last_note_at, 0), id
|
||||||
|
|
||||||
|
@@ show note query
|
||||||
|
-ORDER BY position
|
||||||
|
+ORDER BY COALESCE(position, 9223372036854775807), created_at, id
|
||||||
|
|
||||||
|
@@ 3c. SQL Query
|
||||||
|
-ORDER BY {sort_column} {order}
|
||||||
|
+ORDER BY COALESCE({sort_column}, 0) {order}, fd.id {order}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Make write-bridging more useful with optional command hints**
|
||||||
|
Rationale: Exposing IDs is necessary but not sufficient; agents still need to assemble endpoints repeatedly. Add optional `--with-write-hints` that injects compact endpoint templates (`reply`, `resolve`) derived from row context. This improves usability without bloating default output.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 3a. CLI Args
|
||||||
|
+ /// Include machine-actionable glab write hints per row
|
||||||
|
+ #[arg(long, help_heading = "Output")]
|
||||||
|
+ pub with_write_hints: bool,
|
||||||
|
|
||||||
|
@@ Response Schema (notes/discussions/show)
|
||||||
|
+ "write_hints?": {
|
||||||
|
+ "reply_endpoint": "string",
|
||||||
|
+ "resolve_endpoint?": "string"
|
||||||
|
+ }
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Upgrade robot-docs/contract validation from string-contains to parity checks**
|
||||||
|
Rationale: `contains("gitlab_discussion_id")` catches very little and allows schema drift. Build field-set parity tests that compare actual serialized JSON keys to robot-docs declared fields for `notes`, `discussions`, and `show` discussion nodes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ 4f. Add robot-docs contract tests
|
||||||
|
-assert!(notes_schema.contains("gitlab_discussion_id"));
|
||||||
|
+let declared = parse_schema_field_list(notes_schema);
|
||||||
|
+let sample = sample_notes_row_json_keys();
|
||||||
|
+assert_required_subset(&declared, &["project_path","noteable_type","parent_iid","gitlab_discussion_id","gitlab_note_id"]);
|
||||||
|
+assert_schema_matches_payload(&declared, &sample);
|
||||||
|
|
||||||
|
@@ 4g. Add CLI-level contract integration tests
|
||||||
|
+Add parity tests for:
|
||||||
|
+- notes list JSON
|
||||||
|
+- discussions list JSON
|
||||||
|
+- issues show discussions[*]
|
||||||
|
+- mrs show discussions[*]
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can produce a full revised v3 plan text with these edits merged end-to-end so it’s ready to execute directly.
|
||||||
207
docs/plan-expose-discussion-ids.feedback-4.md
Normal file
207
docs/plan-expose-discussion-ids.feedback-4.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
Below are the highest-impact revisions I’d make to this plan. I excluded everything listed in your `## Rejected Recommendations` section.
|
||||||
|
|
||||||
|
**1. Fix a correctness bug in the ambiguity guardrail (must run before `LIMIT`)**
|
||||||
|
|
||||||
|
The current post-query ambiguity check can silently fail when `--limit` truncates results to one project even though multiple projects match the same `gitlab_discussion_id`. That creates non-deterministic write targeting risk.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Ambiguity Guardrail
|
||||||
|
-**Implementation**: After the main query, if `gitlab_discussion_id` is set and no `--project`
|
||||||
|
-was provided, check if the result set spans multiple `project_path` values.
|
||||||
|
+**Implementation**: Run a preflight distinct-project check when `gitlab_discussion_id` is set
|
||||||
|
+and `--project` was not provided, before the main list query applies `LIMIT`.
|
||||||
|
+Use:
|
||||||
|
+```sql
|
||||||
|
+SELECT DISTINCT p.path_with_namespace
|
||||||
|
+FROM discussions d
|
||||||
|
+JOIN projects p ON p.id = d.project_id
|
||||||
|
+WHERE d.gitlab_discussion_id = ?
|
||||||
|
+LIMIT 3
|
||||||
|
+```
|
||||||
|
+If more than one project is found, return `LoreError::Ambiguous` (exit code 18) with project
|
||||||
|
+paths and suggestion to retry with `--project <path>`.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**2. Add `gitlab_project_id` to the Bridge Contract**
|
||||||
|
|
||||||
|
`project_path` is human-friendly but mutable (renames/transfers). `gitlab_project_id` gives a stable write target and avoids path re-resolution failures.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Bridge Contract (Cross-Cutting)
|
||||||
|
Every read payload that surfaces notes or discussions **MUST** include:
|
||||||
|
- `project_path`
|
||||||
|
+- `gitlab_project_id`
|
||||||
|
- `noteable_type`
|
||||||
|
- `parent_iid`
|
||||||
|
- `gitlab_discussion_id`
|
||||||
|
- `gitlab_note_id`
|
||||||
|
@@
|
||||||
|
const BRIDGE_FIELDS_NOTES: &[&str] = &[
|
||||||
|
- "project_path", "noteable_type", "parent_iid",
|
||||||
|
+ "project_path", "gitlab_project_id", "noteable_type", "parent_iid",
|
||||||
|
"gitlab_discussion_id", "gitlab_note_id",
|
||||||
|
];
|
||||||
|
const BRIDGE_FIELDS_DISCUSSIONS: &[&str] = &[
|
||||||
|
- "project_path", "noteable_type", "parent_iid",
|
||||||
|
+ "project_path", "gitlab_project_id", "noteable_type", "parent_iid",
|
||||||
|
"gitlab_discussion_id",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**3. Replace stringly-typed filter/sort fields with enums end-to-end**
|
||||||
|
|
||||||
|
Right now `sort`, `order`, `resolution`, `noteable_type` are mostly `String`. This is fragile and risks unsafe SQL interpolation drift over time. Typed enums make invalid states unrepresentable.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## 3a. CLI Args
|
||||||
|
- pub resolution: Option<String>,
|
||||||
|
+ pub resolution: Option<ResolutionFilter>,
|
||||||
|
@@
|
||||||
|
- pub noteable_type: Option<String>,
|
||||||
|
+ pub noteable_type: Option<NoteableTypeFilter>,
|
||||||
|
@@
|
||||||
|
- pub sort: String,
|
||||||
|
+ pub sort: DiscussionSortField,
|
||||||
|
@@
|
||||||
|
- pub asc: bool,
|
||||||
|
+ pub order: SortDirection,
|
||||||
|
@@ ## 3d. Filters struct
|
||||||
|
- pub resolution: Option<String>,
|
||||||
|
- pub noteable_type: Option<String>,
|
||||||
|
- pub sort: String,
|
||||||
|
- pub order: String,
|
||||||
|
+ pub resolution: Option<ResolutionFilter>,
|
||||||
|
+ pub noteable_type: Option<NoteableTypeFilter>,
|
||||||
|
+ pub sort: DiscussionSortField,
|
||||||
|
+ pub order: SortDirection,
|
||||||
|
@@
|
||||||
|
+Map enum -> SQL fragment via `match` in query builder; never interpolate raw strings.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**4. Enforce snapshot consistency for multi-query commands**
|
||||||
|
|
||||||
|
`discussions` with `--include-notes` does multiple reads. Without a single read transaction, concurrent ingest can produce mismatched `total_count`, row set, and expanded notes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## 3c. SQL Query
|
||||||
|
-pub fn query_discussions(...)
|
||||||
|
+pub fn query_discussions(...)
|
||||||
|
{
|
||||||
|
+ // Run count query + page query + note expansion under one deferred read transaction
|
||||||
|
+ // so output is a single consistent snapshot.
|
||||||
|
+ let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
|
||||||
|
...
|
||||||
|
+ tx.commit()?;
|
||||||
|
}
|
||||||
|
@@ ## 1. Add `gitlab_discussion_id` to Notes Output
|
||||||
|
+Apply the same snapshot rule to `query_notes` when returning `total_count` + paged rows.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**5. Correct first-note rollup semantics (current CTE can return null/incorrect `first_author`)**
|
||||||
|
|
||||||
|
In the proposed SQL, `rn=1` is computed over all notes but then filtered with `is_system=0`, so threads with a leading system note may incorrectly lose `first_author`/snippet. Also path rollup uses non-deterministic `MAX(...)`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## 3c. SQL Query
|
||||||
|
-ranked_notes AS (
|
||||||
|
+ranked_notes AS (
|
||||||
|
SELECT
|
||||||
|
n.discussion_id,
|
||||||
|
n.author_username,
|
||||||
|
n.body,
|
||||||
|
n.is_system,
|
||||||
|
n.position_new_path,
|
||||||
|
n.position_new_line,
|
||||||
|
- ROW_NUMBER() OVER (
|
||||||
|
- PARTITION BY n.discussion_id
|
||||||
|
- ORDER BY n.position, n.id
|
||||||
|
- ) AS rn
|
||||||
|
+ ROW_NUMBER() OVER (
|
||||||
|
+ PARTITION BY n.discussion_id
|
||||||
|
+ ORDER BY CASE WHEN n.is_system = 0 THEN 0 ELSE 1 END, n.created_at, n.id
|
||||||
|
+ ) AS rn_first_note,
|
||||||
|
+ ROW_NUMBER() OVER (
|
||||||
|
+ PARTITION BY n.discussion_id
|
||||||
|
+ ORDER BY CASE WHEN n.position_new_path IS NULL THEN 1 ELSE 0 END, n.created_at, n.id
|
||||||
|
+ ) AS rn_first_position
|
||||||
|
@@
|
||||||
|
- MAX(CASE WHEN rn = 1 AND is_system = 0 THEN author_username END) AS first_author,
|
||||||
|
- MAX(CASE WHEN rn = 1 AND is_system = 0 THEN body END) AS first_note_body,
|
||||||
|
- MAX(CASE WHEN position_new_path IS NOT NULL THEN position_new_path END) AS position_new_path,
|
||||||
|
- MAX(CASE WHEN position_new_line IS NOT NULL THEN position_new_line END) AS position_new_line
|
||||||
|
+ MAX(CASE WHEN rn_first_note = 1 AND is_system = 0 THEN author_username END) AS first_author,
|
||||||
|
+ MAX(CASE WHEN rn_first_note = 1 AND is_system = 0 THEN body END) AS first_note_body,
|
||||||
|
+ MAX(CASE WHEN rn_first_position = 1 THEN position_new_path END) AS position_new_path,
|
||||||
|
+ MAX(CASE WHEN rn_first_position = 1 THEN position_new_line END) AS position_new_line
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**6. Add per-discussion truncation signals for `--include-notes`**
|
||||||
|
|
||||||
|
Top-level `has_more` is useful, but agents also need to know if an individual thread’s notes were truncated. Otherwise they can’t tell if a thread is complete.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Response Schema
|
||||||
|
{
|
||||||
|
"gitlab_discussion_id": "...",
|
||||||
|
...
|
||||||
|
- "notes": []
|
||||||
|
+ "included_note_count": 0,
|
||||||
|
+ "has_more_notes": false,
|
||||||
|
+ "notes": []
|
||||||
|
}
|
||||||
|
@@ ## 3b. Domain Structs
|
||||||
|
pub struct DiscussionListRowJson {
|
||||||
|
@@
|
||||||
|
+ pub included_note_count: usize,
|
||||||
|
+ pub has_more_notes: bool,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub notes: Vec<NoteListRowJson>,
|
||||||
|
}
|
||||||
|
@@ ## 3c-ii. Note expansion query (--include-notes)
|
||||||
|
-Group by `discussion_id` in Rust and attach notes arrays...
|
||||||
|
+Group by `discussion_id` in Rust, attach notes arrays, and set:
|
||||||
|
+`included_note_count = notes.len()`,
|
||||||
|
+`has_more_notes = note_count > included_note_count`.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**7. Add explicit query-plan gate and targeted index workstream (measured, not speculative)**
|
||||||
|
|
||||||
|
This plan introduces heavy discussion-centric reads. You should bake in deterministic performance validation with `EXPLAIN QUERY PLAN` and only then add indexes if missing.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Scope: Four workstreams, delivered in order:
|
||||||
|
-4. Fix robot-docs to list actual field names instead of opaque type references
|
||||||
|
+4. Add query-plan validation + targeted index updates for new discussion queries
|
||||||
|
+5. Fix robot-docs to list actual field names instead of opaque type references
|
||||||
|
@@
|
||||||
|
+## 4. Query-Plan Validation and Targeted Indexes
|
||||||
|
+
|
||||||
|
+Before and after implementing `query_discussions`, capture `EXPLAIN QUERY PLAN` for:
|
||||||
|
+- `--for-mr <iid> --resolution unresolved`
|
||||||
|
+- `--project <path> --since 7d --sort last_note`
|
||||||
|
+- `--gitlab-discussion-id <id>`
|
||||||
|
+
|
||||||
|
+If plans show table scans on `notes`/`discussions`, add indexes in `MIGRATIONS` array:
|
||||||
|
+- `discussions(project_id, gitlab_discussion_id)`
|
||||||
|
+- `discussions(merge_request_id, last_note_at, id)`
|
||||||
|
+- `notes(discussion_id, created_at DESC, id DESC)`
|
||||||
|
+- `notes(discussion_id, position, id)`
|
||||||
|
+
|
||||||
|
+Tests: assert the new query paths return expected rows under indexed schema and no regressions.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated “iteration 4” version of the plan text with all seven revisions merged in place.
|
||||||
160
docs/plan-expose-discussion-ids.feedback-4.md.bak
Normal file
160
docs/plan-expose-discussion-ids.feedback-4.md.bak
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
I reviewed the plan end-to-end and focused only on new improvements (none of the items in `## Rejected Recommendations` are re-proposed).
|
||||||
|
|
||||||
|
1. Add direct `--discussion-id` retrieval paths
|
||||||
|
Rationale: This removes a full discovery hop for the exact workflow that failed (replying to a known thread). It also reduces ambiguity and query cost when an agent already has the thread ID.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Core Changes
|
||||||
|
| 7 | Fix robot-docs to list actual field names | Docs | Small |
|
||||||
|
+| 8 | Add direct `--discussion-id` filter to notes/discussions/show | Core | Small |
|
||||||
|
|
||||||
|
@@ Change 3: Add Standalone `discussions` List Command
|
||||||
|
lore -J discussions --for-mr 99 --cursor <token> # keyset pagination
|
||||||
|
+lore -J discussions --discussion-id 6a9c1750b37d... # direct lookup
|
||||||
|
|
||||||
|
@@ 3a. CLI Args
|
||||||
|
+ #[arg(long, conflicts_with_all = ["for_issue", "for_mr"], help_heading = "Filters")]
|
||||||
|
+ pub discussion_id: Option<String>,
|
||||||
|
|
||||||
|
@@ Change 1: Add `gitlab_discussion_id` to Notes Output
|
||||||
|
+Add `--discussion-id <hex>` filter to `notes` for direct note retrieval within one thread.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add a shared filter compiler to eliminate count/query drift
|
||||||
|
Rationale: The plan currently repeats filters across data query, `total_count`, and `incomplete_rows` count queries. That is a classic reliability bug source. A single compiled filter object makes count semantics provably consistent.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Count Semantics (Cross-Cutting Convention)
|
||||||
|
+## Filter Compiler (NEW, Cross-Cutting Convention)
|
||||||
|
+All list commands must build predicates via a shared `CompiledFilters` object that emits:
|
||||||
|
+- SQL predicate fragment
|
||||||
|
+- bind parameters
|
||||||
|
+- canonical filter string (for cursor hash)
|
||||||
|
+The same compiled object is reused by:
|
||||||
|
+- page data query
|
||||||
|
+- `total_count` query
|
||||||
|
+- `incomplete_rows` query
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Harden keyset pagination semantics for `DESC`, limits, and client ergonomics
|
||||||
|
Rationale: `(sort_value, id) > (?, ?)` is only correct for ascending order. Descending sort needs `<`. Also add explicit `has_more` so clients don’t infer from cursor nullability.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Keyset Pagination (Cross-Cutting, Change B)
|
||||||
|
-```sql
|
||||||
|
-WHERE (sort_value, id) > (?, ?)
|
||||||
|
-```
|
||||||
|
+Use comparator by order:
|
||||||
|
+- ASC: `(sort_value, id) > (?, ?)`
|
||||||
|
+- DESC: `(sort_value, id) < (?, ?)`
|
||||||
|
|
||||||
|
@@ 3a. CLI Args
|
||||||
|
+ #[arg(short = 'n', long = "limit", default_value = "50", value_parser = clap::value_parser!(usize).range(1..=500), help_heading = "Output")]
|
||||||
|
+ pub limit: usize,
|
||||||
|
|
||||||
|
@@ Response Schema
|
||||||
|
- "next_cursor": "aW...xyz=="
|
||||||
|
+ "next_cursor": "aW...xyz==",
|
||||||
|
+ "has_more": true
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add DB-level entity integrity invariants (not just response invariants)
|
||||||
|
Rationale: Response-side filtering is good, but DB correctness should also be guarded. This prevents silent corruption and bad joins from ingestion or future migrations.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Contract Invariants (NEW)
|
||||||
|
+### Entity Integrity Invariants (DB + Ingest)
|
||||||
|
+1. `discussions` must belong to exactly one parent (`issue_id XOR merge_request_id`).
|
||||||
|
+2. `discussions.noteable_type` must match the populated parent column.
|
||||||
|
+3. Natural-key uniqueness is enforced where valid:
|
||||||
|
+ - `(project_id, gitlab_discussion_id)` unique for discussions.
|
||||||
|
+4. Ingestion must reject/quarantine rows violating invariants and report counts.
|
||||||
|
|
||||||
|
@@ Supporting Indexes (Cross-Cutting, Change D)
|
||||||
|
+CREATE UNIQUE INDEX IF NOT EXISTS idx_discussions_project_gitlab_discussion_id
|
||||||
|
+ ON discussions(project_id, gitlab_discussion_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Switch bulk note loading to streaming grouping (avoid large intermediate vecs)
|
||||||
|
Rationale: Current bulk strategy still materializes all notes before grouping. Streaming into the map cuts peak memory and improves large-MR stability.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Change 2e. Constructor — use bulk notes map
|
||||||
|
-let all_note_rows: Vec<MrNoteDetail> = ... // From bulk query above
|
||||||
|
-let notes_by_discussion: HashMap<i64, Vec<MrNoteDetail>> =
|
||||||
|
- all_note_rows.into_iter().fold(HashMap::new(), |mut map, note| {
|
||||||
|
- map.entry(note.discussion_id).or_insert_with(Vec::new).push(note);
|
||||||
|
- map
|
||||||
|
- });
|
||||||
|
+let mut notes_by_discussion: HashMap<i64, Vec<MrNoteDetail>> = HashMap::new();
|
||||||
|
+for row in bulk_note_stmt.query_map(params, map_note_row)? {
|
||||||
|
+ let note = row?;
|
||||||
|
+ notes_by_discussion.entry(note.discussion_id).or_default().push(note);
|
||||||
|
+}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Make freshness tri-state (`fresh|stale|unknown`) and fail closed on unknown with `--require-fresh`
|
||||||
|
Rationale: `stale: bool` alone cannot represent “never synced / unknown project freshness.” For write safety, unknown freshness should be explicit and reject under freshness constraints.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Freshness Metadata & Staleness Guards
|
||||||
|
pub struct ResponseMeta {
|
||||||
|
pub elapsed_ms: i64,
|
||||||
|
pub data_as_of_iso: String,
|
||||||
|
pub sync_lag_seconds: i64,
|
||||||
|
pub stale: bool,
|
||||||
|
+ pub freshness_state: String, // "fresh" | "stale" | "unknown"
|
||||||
|
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
+ pub freshness_reason: Option<String>,
|
||||||
|
pub incomplete_rows: i64,
|
||||||
|
@@
|
||||||
|
-if sync_lag_seconds > max_age_secs {
|
||||||
|
+if freshness_state == "unknown" || sync_lag_seconds > max_age_secs {
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Tune indexes to match actual ORDER BY paths in window queries
|
||||||
|
Rationale: `idx_notes_discussion_position` is likely insufficient for the two window orderings. A covering-style index aligned with partition/order keys reduces random table lookups.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Supporting Indexes (Cross-Cutting, Change D)
|
||||||
|
--- Notes: window function ORDER BY (discussion_id, position) for ROW_NUMBER()
|
||||||
|
-CREATE INDEX IF NOT EXISTS idx_notes_discussion_position
|
||||||
|
- ON notes(discussion_id, position);
|
||||||
|
+-- Notes: support dual ROW_NUMBER() orderings and reduce table lookups
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_discussion_window
|
||||||
|
+ ON notes(discussion_id, is_system, position, created_at, gitlab_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Add a phased rollout gate before strict exclusion becomes default
|
||||||
|
Rationale: Enforcing `gitlab_* IS NOT NULL` immediately can hide data if existing rows are incomplete. A short observation gate prevents sudden regressions while preserving the end-state contract.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Delivery Order
|
||||||
|
+Batch 0: Observability gate (NEW)
|
||||||
|
+- Ship `incomplete_rows` and freshness meta first
|
||||||
|
+- Measure incomplete rate across real datasets
|
||||||
|
+- If incomplete ratio <= threshold, enable strict exclusion defaults
|
||||||
|
+- If above threshold, block rollout and fix ingestion quality first
|
||||||
|
+
|
||||||
|
Change 1 (notes output) ──┐
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Add property-based invariants for pagination/count correctness
|
||||||
|
Rationale: Your current tests are scenario-based and good, but randomized property tests are much better at catching edge-case cursor/count bugs.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Tests (Change 3 / Change B)
|
||||||
|
+**Test 12**: Property-based pagination invariants (`proptest`)
|
||||||
|
+```rust
|
||||||
|
+#[test]
|
||||||
|
+fn prop_discussion_cursor_no_overlap_no_gap_under_random_data() { /* ... */ }
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+**Test 13**: Property-based count invariants
|
||||||
|
+```rust
|
||||||
|
+#[test]
|
||||||
|
+fn prop_total_count_and_incomplete_rows_match_filter_partition() { /* ... */ }
|
||||||
|
+```
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can now produce a fully consolidated “Plan v4” that applies these diffs cleanly into your original document so it reads as a single coherent spec.
|
||||||
158
docs/plan-expose-discussion-ids.feedback-5.md.bak
Normal file
158
docs/plan-expose-discussion-ids.feedback-5.md.bak
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
I reviewed the whole plan and only proposed changes that are not in your `## Rejected Recommendations`.
|
||||||
|
|
||||||
|
1. **Fix plan-internal inconsistencies first**
|
||||||
|
Analysis: The plan currently has a few self-contradictions (`8` vs `9` cross-cutting improvements, `stale` still referenced after moving to tri-state freshness). Cleaning this prevents implementation drift and bad AC validation.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@
|
||||||
|
-**Scope**: 8 core changes + 8 cross-cutting architectural improvements across 3 tiers:
|
||||||
|
+**Scope**: 8 core changes + 9 cross-cutting architectural improvements across 3 tiers:
|
||||||
|
@@ AC-7: Freshness Metadata Present & Staleness Guards Work
|
||||||
|
-lore -J notes -n 1 | jq '.meta | {data_as_of_iso, sync_lag_seconds, stale}'
|
||||||
|
-# All fields present, stale=false if recently synced
|
||||||
|
+lore -J notes -n 1 | jq '.meta | {data_as_of_iso, sync_lag_seconds, freshness_state}'
|
||||||
|
+# All fields present, freshness_state is one of fresh|stale|unknown
|
||||||
|
@@ Change 6 Response Schema example
|
||||||
|
- "stale": false,
|
||||||
|
+ "freshness_state": "fresh",
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Require snapshot-consistent list responses (page + counts)**
|
||||||
|
Analysis: `total_count`, `incomplete_rows`, and page rows can drift if sync writes between queries. Enforcing a single read snapshot for all list commands makes pagination and counts deterministic.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ Count Semantics (Cross-Cutting Convention)
|
||||||
|
All list commands use consistent count fields:
|
||||||
|
+All three queries (`page`, `total_count`, `incomplete_rows`) MUST execute inside one read transaction/snapshot.
|
||||||
|
+This guarantees count/page consistency under concurrent sync writes.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use RAII transactions instead of manual `BEGIN/COMMIT`**
|
||||||
|
Analysis: Manual `execute_batch("BEGIN...")` is fragile on early returns. `rusqlite::Transaction` guarantees rollback on error and removes transaction-leak risk.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ Change 2: Consistency guarantee
|
||||||
|
-conn.execute_batch("BEGIN DEFERRED")?;
|
||||||
|
-// ... discussion query ...
|
||||||
|
-// ... bulk note query ...
|
||||||
|
-conn.execute_batch("COMMIT")?;
|
||||||
|
+let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
|
||||||
|
+// ... discussion query ...
|
||||||
|
+// ... bulk note query ...
|
||||||
|
+tx.commit()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Allow small focused new modules for query infrastructure**
|
||||||
|
Analysis: Keeping everything in `list.rs`/`show.rs` will become a maintenance hotspot as filters/cursors/freshness expand. A small module split reduces coupling and regression risk.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ Change 3: File Architecture
|
||||||
|
-**No new files.** Follow existing patterns:
|
||||||
|
+Allow focused infra modules for shared logic:
|
||||||
|
+- `src/cli/query/filters.rs` (CompiledFilters + builders)
|
||||||
|
+- `src/cli/query/cursor.rs` (encode/decode/validate v2 cursors)
|
||||||
|
+- `src/cli/query/freshness.rs` (freshness computation + guards)
|
||||||
|
+Command handlers remain in existing files.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add ingest-time `discussion_rollups` to avoid repeated heavy window scans**
|
||||||
|
Analysis: Window functions are good, but doing them on every read over large note volumes is still expensive. Precomputing rollups during ingest gives lower and more predictable p95 latency while keeping read paths simpler.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ Architectural Improvements (Cross-Cutting)
|
||||||
|
+| J | Ingest-time discussion rollups (`discussion_rollups`) | Performance | Medium |
|
||||||
|
@@ Change 3 SQL strategy
|
||||||
|
-Use `ROW_NUMBER()` window function instead of correlated subqueries...
|
||||||
|
+Primary path: join precomputed `discussion_rollups` for `note_count`, `first_author`,
|
||||||
|
+`first_note_body`, `position_new_path`, `position_new_line`.
|
||||||
|
+Fallback path: window-function recompute if rollup row is missing (defensive correctness).
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add deterministic numeric project selector `--project-id`**
|
||||||
|
Analysis: `-p group/repo` is human-friendly, but numeric project IDs are safer for robots and avoid fuzzy/project-path ambiguity. This reduces false ambiguity failures and lookup overhead.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ DiscussionsArgs
|
||||||
|
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||||
|
pub project: Option<String>,
|
||||||
|
+ #[arg(long, conflicts_with = "project", help_heading = "Filters")]
|
||||||
|
+ pub project_id: Option<i64>,
|
||||||
|
@@ Ambiguity handling
|
||||||
|
+If `--project-id` is provided, IID resolution is scoped directly to that project.
|
||||||
|
+`--project-id` takes precedence over path-based project matching.
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Make path filtering rename-aware (`old` + `new`)**
|
||||||
|
Analysis: Current `--path` strategy only using `position_new_path` misses deleted/renamed-file discussions. Supporting side selection makes the feature materially more useful for review workflows.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ DiscussionsArgs
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
+ #[arg(long, value_parser = ["either", "new", "old"], default_value = "either", help_heading = "Filters")]
|
||||||
|
+ pub path_side: String,
|
||||||
|
@@ Change 3 filtering
|
||||||
|
-Path filter matches `position_new_path`.
|
||||||
|
+Path filter semantics:
|
||||||
|
+- `either` (default): match `position_new_path` OR `position_old_path`
|
||||||
|
+- `new`: match only `position_new_path`
|
||||||
|
+- `old`: match only `position_old_path`
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Add explicit freshness behavior for empty-result queries + bootstrap backfill**
|
||||||
|
Analysis: Freshness based only on “participating rows” is undefined when results are empty. Define deterministic behavior and backfill `project_sync_state` on migration so `unknown` doesn’t spike unexpectedly after deploy.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ Freshness state logic
|
||||||
|
+Empty-result rules:
|
||||||
|
+- If query is project-scoped (`-p` or `--project-id`), freshness is computed from that project even when no rows match.
|
||||||
|
+- If query is unscoped and returns zero rows, freshness is computed from all tracked projects.
|
||||||
|
@@ A1. Track per-project sync timestamp
|
||||||
|
+Migration step: seed `project_sync_state` from latest known sync metadata where available
|
||||||
|
+to avoid mass `unknown` freshness immediately after rollout.
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Upgrade `--discussion-id` from filter-only to first-class thread retrieval**
|
||||||
|
Analysis: Filtering list output by discussion ID still returns list-shaped data and partial note context. A direct thread retrieval mode is faster for agent workflows and avoids extra commands.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ Core Changes
|
||||||
|
-| 8 | Add direct `--discussion-id` filter to notes/discussions/show | Core | Small |
|
||||||
|
+| 8 | Add direct `--discussion-id` filter + single-thread retrieval mode | Core | Medium |
|
||||||
|
@@ Change 8
|
||||||
|
+lore -J discussions --discussion-id <id> --full-thread
|
||||||
|
+# Returns one discussion with full notes payload (same note schema as show command).
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Replace ad-hoc AC performance timing with repeatable perf harness**
|
||||||
|
Analysis: `time lore ...` is noisy and machine-dependent. A reproducible seeded benchmark test gives stable guardrails and catches regressions earlier.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/plan.md
|
||||||
|
+++ b/plan.md
|
||||||
|
@@ AC-10: Performance Budget
|
||||||
|
-time lore -J discussions --for-mr <iid> -n 100
|
||||||
|
-# real 0m0.100s (p95 < 150ms)
|
||||||
|
+cargo test --test perf_discussions -- --ignored --nocapture
|
||||||
|
+# Uses seeded fixture DB and N repeated runs; asserts p95 < 150ms for target query shape.
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can also produce a fully merged “iteration 5” rewritten plan document with these edits applied end-to-end so it’s directly executable by an implementation agent.
|
||||||
143
docs/plan-expose-discussion-ids.feedback-6.md.bak
Normal file
143
docs/plan-expose-discussion-ids.feedback-6.md.bak
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
Strong plan overall. The biggest gaps I’d fix are around sync-health correctness, idempotency/integrity under repeated ingests, deleted-entity lifecycle, and reducing schema drift risk without heavy reflection machinery.
|
||||||
|
|
||||||
|
I avoided everything in your `## Rejected Recommendations` section.
|
||||||
|
|
||||||
|
**1. Add Sync Health Semantics (not just age)**
|
||||||
|
Time freshness alone can mislead after partial/failed syncs. Agents need to know whether data is both recent and complete.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Freshness Metadata & Staleness Guards (Cross-Cutting, Change A/F/G)
|
||||||
|
- pub freshness_state: String, // "fresh" | "stale" | "unknown"
|
||||||
|
+ pub freshness_state: String, // "fresh" | "stale" | "unknown"
|
||||||
|
+ pub sync_status: String, // "ok" | "partial" | "failed" | "never"
|
||||||
|
+ pub last_successful_sync_run_id: Option<i64>,
|
||||||
|
+ pub last_attempted_sync_run_id: Option<i64>,
|
||||||
|
@@
|
||||||
|
-#[arg(long, help_heading = "Freshness")]
|
||||||
|
-pub require_fresh: Option<String>,
|
||||||
|
+#[arg(long, help_heading = "Freshness")]
|
||||||
|
+pub require_fresh: Option<String>,
|
||||||
|
+#[arg(long, help_heading = "Freshness")]
|
||||||
|
+pub require_sync_ok: bool,
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: this prevents false confidence when one project is fresh-by-time but latest sync actually failed or was partial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**2. Add `--require-complete` Guard for Missing Required IDs**
|
||||||
|
You already expose `meta.incomplete_rows`; add a hard gate for automation.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Count Semantics (Cross-Cutting Convention)
|
||||||
|
`incomplete_rows` is computed via a dedicated COUNT query...
|
||||||
|
+Add CLI guard:
|
||||||
|
+`--require-complete` fails with exit code 19 when `meta.incomplete_rows > 0`.
|
||||||
|
+Suggested action: `lore sync --full`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: agents can fail fast instead of silently acting on partial datasets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**3. Strengthen Ingestion Idempotency + Referential Integrity for Notes**
|
||||||
|
You added natural-key uniqueness for discussions; do the same for notes and enforce parent integrity at DB level.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Supporting Indexes (Cross-Cutting, Change D)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_discussions_project_gitlab_discussion_id
|
||||||
|
ON discussions(project_id, gitlab_discussion_id);
|
||||||
|
+CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_gitlab_id
|
||||||
|
+ ON notes(project_id, gitlab_id);
|
||||||
|
+
|
||||||
|
+-- Referential integrity
|
||||||
|
+-- notes.discussion_id REFERENCES discussions(id)
|
||||||
|
+-- notes.project_id REFERENCES projects(id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: repeated syncs and retries won’t duplicate notes, and orphaned rows can’t accumulate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**4. Add Deleted/Tombstoned Entity Lifecycle**
|
||||||
|
Current plan excludes null IDs but doesn’t define behavior when GitLab entities are deleted after sync.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Contract Invariants (NEW)
|
||||||
|
+### Deletion Lifecycle Invariant
|
||||||
|
+1. Notes/discussions deleted upstream are tombstoned locally (`deleted_at`), not hard-deleted.
|
||||||
|
+2. All list/show commands exclude tombstoned rows by default.
|
||||||
|
+3. Optional flag `--include-deleted` exposes tombstoned rows for audit/debug.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: preserves auditability, prevents ghost actions on deleted objects, and avoids destructive resync behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**5. Expand Discussions Payload for Rename Accuracy + Better Triage**
|
||||||
|
`--path-side old` is great, but output currently only returns `position_new_*`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Change 3: Add Standalone `discussions` List Command
|
||||||
|
pub position_new_path: Option<String>,
|
||||||
|
pub position_new_line: Option<i64>,
|
||||||
|
+ pub position_old_path: Option<String>,
|
||||||
|
+ pub position_old_line: Option<i64>,
|
||||||
|
+ pub last_author: Option<String>,
|
||||||
|
+ pub participant_usernames: Vec<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: for renamed/deleted files, agents need old and new coordinates to act confidently; participants/last_author improve thread routing and prioritization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**6. Add SQLite Busy Handling + Retry Policy**
|
||||||
|
Read transactions + concurrent sync writes can still produce `SQLITE_BUSY` under load.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Count Semantics (Cross-Cutting Convention)
|
||||||
|
**Snapshot consistency**: All three queries ... inside a single read transaction ...
|
||||||
|
+**Busy handling**: set `PRAGMA busy_timeout` (e.g. 5000ms) and retry transient
|
||||||
|
+`SQLITE_BUSY` errors up to 3 times with jittered backoff for read commands.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: improves reliability in real multi-agent usage without changing semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**7. Make Field Definitions Single-Source (Lightweight Drift Prevention)**
|
||||||
|
You rejected full schema generation from code; a lower-cost middle ground is shared field manifests used by both docs and `--fields` validation.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Change 7: Fix Robot-Docs Response Schemas
|
||||||
|
+#### 7h. Single-source field manifests (no reflection)
|
||||||
|
+Define per-command field constants (e.g. `NOTES_FIELDS`, `DISCUSSIONS_FIELDS`)
|
||||||
|
+used by:
|
||||||
|
+1) `--fields` validation/filtering
|
||||||
|
+2) `--fields minimal` expansion
|
||||||
|
+3) `robot-docs` schema rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: cuts drift risk materially while staying much simpler than reflection/snapshot infra.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**8. De-duplicate and Upgrade Test Strategy Around Concurrency**
|
||||||
|
There are duplicated tests across Change 2 and Change 3; add explicit race tests where sync writes happen between list subqueries to prove tx consistency.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Tests
|
||||||
|
-**Test 6**: `--project-id` scopes IID resolution directly
|
||||||
|
-**Test 7**: `--path-side old` matches renamed file discussions
|
||||||
|
-**Test 8**: `--path-side either` matches both old and new paths
|
||||||
|
+Move shared discussion-filter tests to a single section under Change 3.
|
||||||
|
+Add concurrency tests:
|
||||||
|
+1) count/page/incomplete consistency under concurrent sync writes
|
||||||
|
+2) show discussion+notes snapshot consistency under concurrent writes
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: less maintenance noise, better coverage of your highest-risk correctness path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can also produce a single consolidated patch block that rewrites your plan text end-to-end with these edits applied in-place.
|
||||||
1907
docs/plan-expose-discussion-ids.md
Normal file
1907
docs/plan-expose-discussion-ids.md
Normal file
File diff suppressed because it is too large
Load Diff
169
docs/plan-surgical-sync.feedback-3.md
Normal file
169
docs/plan-surgical-sync.feedback-3.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
Below are the strongest **new** revisions I’d make (excluding everything in your rejected list), with rationale and plan-level diffs.
|
||||||
|
|
||||||
|
### 1. Add a durable run ledger (`sync_runs`) with phase state
|
||||||
|
This makes surgical sync crash-resumable, auditable, and safer under Ctrl+C. Right now `run_id` is mostly ephemeral; persisting phase state removes ambiguity about what completed.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
+9. **Durable run state**: Surgical sync MUST persist a `sync_runs` row keyed by `run_id`
|
||||||
|
+ with phase transitions (`preflight`, `ingest`, `dependents`, `docs`, `embed`, `done`, `failed`).
|
||||||
|
+ This is required for crash recovery, observability, and deterministic retries.
|
||||||
|
|
||||||
|
@@ Step 9: Create `run_sync_surgical`
|
||||||
|
+Before Stage 0, insert `sync_runs(run_id, project_id, mode='surgical', requested_counts, started_at)`.
|
||||||
|
+After each stage, update `sync_runs.phase`, counters, and `last_error` if present.
|
||||||
|
+On success/failure, set terminal state (`done`/`failed`) and `finished_at`.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add `--preflight-only` (network validation without writes)
|
||||||
|
`--dry-run` is intentionally zero-network, so it cannot validate IIDs. `--preflight-only` is high-value for agents: verifies existence/permissions quickly with no DB mutation.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ CLI Interface
|
||||||
|
lore sync --dry-run --issue 123 -p myproject
|
||||||
|
+lore sync --preflight-only --issue 123 -p myproject
|
||||||
|
|
||||||
|
@@ Step 2: Add `--issue`, `--mr`, `-p` to `SyncArgs`
|
||||||
|
+ /// Validate remote entities and auth without any DB writes
|
||||||
|
+ #[arg(long, default_value_t = false)]
|
||||||
|
+ pub preflight_only: bool,
|
||||||
|
|
||||||
|
@@ Step 10: Add branch in `run_sync`
|
||||||
|
+if options.preflight_only && options.is_surgical() {
|
||||||
|
+ return run_sync_surgical_preflight_only(config, &options, run_id, signal).await;
|
||||||
|
+}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Preflight should aggregate all missing/failed IIDs, not fail-fast
|
||||||
|
Fail-fast causes repeated reruns. Aggregating errors gives one-shot correction and better robot automation.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 7: Create `src/ingestion/surgical.rs`
|
||||||
|
-/// Returns the fetched payloads. If ANY fetch fails, the entire operation should abort.
|
||||||
|
+/// Returns fetched payloads plus per-IID failures; caller aborts writes if failures exist.
|
||||||
|
pub async fn preflight_fetch(...) -> Result<PreflightResult> {
|
||||||
|
|
||||||
|
@@
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct PreflightResult {
|
||||||
|
pub issues: Vec<GitLabIssue>,
|
||||||
|
pub merge_requests: Vec<GitLabMergeRequest>,
|
||||||
|
+ pub failures: Vec<EntityFailure>, // stage="fetch"
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ Step 9: Create `run_sync_surgical`
|
||||||
|
-let preflight = preflight_fetch(...).await?;
|
||||||
|
+let preflight = preflight_fetch(...).await?;
|
||||||
|
+if !preflight.failures.is_empty() {
|
||||||
|
+ result.entity_failures = preflight.failures;
|
||||||
|
+ return Err(LoreError::Other("Surgical preflight failed for one or more IIDs".into()).into());
|
||||||
|
+}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Stop filtering scoped queue drains with raw `json_extract` scans
|
||||||
|
`json_extract(payload_json, '$.scope_run_id')` in hot drain queries will degrade as queue grows. Use indexed scope metadata.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 9b: Implement scoped drain helpers
|
||||||
|
-// claim query adds:
|
||||||
|
-// AND json_extract(payload_json, '$.scope_run_id') = ?
|
||||||
|
+// Add migration:
|
||||||
|
+// 1) Add `scope_run_id` generated/stored column derived from payload_json (or explicit column)
|
||||||
|
+// 2) Create index on (project_id, job_type, scope_run_id, status, id)
|
||||||
|
+// Scoped drains filter by indexed `scope_run_id`, not full-table JSON extraction.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Replace `dirty_source_ids` collection-by-query with explicit run scoping
|
||||||
|
Current approach can accidentally include prior dirty rows for same source and can duplicate work. Tag dirty rows with `origin_run_id` and consume by run.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
-2. **Dirty queue scoping**: ... MUST call ... `run_generate_docs_for_dirty_ids`
|
||||||
|
+2. **Dirty queue scoping**: Surgical sync MUST scope docs by `origin_run_id` on `dirty_sources`
|
||||||
|
+ (or equivalent exact run marker) and MUST NOT drain unrelated dirty rows.
|
||||||
|
|
||||||
|
@@ Step 7: `SurgicalIngestResult`
|
||||||
|
- pub dirty_source_ids: Vec<i64>,
|
||||||
|
+ pub origin_run_id: String,
|
||||||
|
|
||||||
|
@@ Step 9a: Implement `run_generate_docs_for_dirty_ids`
|
||||||
|
-pub fn run_generate_docs_for_dirty_ids(config: &Config, dirty_source_ids: &[i64]) -> Result<...>
|
||||||
|
+pub fn run_generate_docs_for_run_id(config: &Config, run_id: &str) -> Result<...>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Enforce transaction safety at the type boundary
|
||||||
|
`unchecked_transaction()` + `&Connection` signatures is fragile. Accept `&Transaction` for ingest internals and use `TransactionBehavior::Immediate` for deterministic lock behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 7: Create `src/ingestion/surgical.rs`
|
||||||
|
-pub fn ingest_issue_by_iid_from_payload(conn: &Connection, ...)
|
||||||
|
+pub fn ingest_issue_by_iid_from_payload(tx: &rusqlite::Transaction<'_>, ...)
|
||||||
|
|
||||||
|
-pub fn ingest_mr_by_iid_from_payload(conn: &Connection, ...)
|
||||||
|
+pub fn ingest_mr_by_iid_from_payload(tx: &rusqlite::Transaction<'_>, ...)
|
||||||
|
|
||||||
|
-let tx = conn.unchecked_transaction()?;
|
||||||
|
+let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Acquire sync lock only for mutation phases, not remote preflight
|
||||||
|
This materially reduces lock contention and keeps normal sync throughput higher, while still guaranteeing mutation serialization.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
+10. **Lock window minimization**: Preflight fetch runs without sync lock; lock is acquired immediately
|
||||||
|
+ before first DB mutation and held through all mutation stages.
|
||||||
|
|
||||||
|
@@ Step 9: Create `run_sync_surgical`
|
||||||
|
-// ── Acquire sync lock ──
|
||||||
|
-...
|
||||||
|
-// ── Stage 0: Preflight fetch ──
|
||||||
|
+// ── Stage 0: Preflight fetch (no lock, no writes) ──
|
||||||
|
...
|
||||||
|
+// ── Acquire sync lock just before Stage 1 mutation ──
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Add explicit transient retry policy beyond 429
|
||||||
|
Client already handles rate limits; surgical reliability improves a lot if 5xx/timeouts are retried with bounded backoff.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
+11. **Transient retry policy**: Preflight and dependent remote fetches MUST retry boundedly on
|
||||||
|
+ timeout/5xx with jittered backoff; permanent errors (404/401/403) fail immediately.
|
||||||
|
|
||||||
|
@@ Step 5: Add `get_issue_by_iid` / `get_mr_by_iid`
|
||||||
|
+Document retry behavior for transient transport/server failures.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Tighten automated tests around scoping invariants
|
||||||
|
You already list manual checks; these should be enforced in unit/integration tests to prevent regressions.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 1: TDD — Write Failing Tests First
|
||||||
|
+### 1d. New invariants tests
|
||||||
|
+- `surgical_docs_scope_ignores_preexisting_dirty_rows`
|
||||||
|
+- `scoped_queue_drain_ignores_orphaned_jobs`
|
||||||
|
+- `preflight_aggregates_multiple_missing_iids`
|
||||||
|
+- `preflight_only_performs_zero_writes`
|
||||||
|
+- `dry_run_performs_zero_network_calls`
|
||||||
|
+- `lock_window_does_not_block_during_preflight`
|
||||||
|
|
||||||
|
@@ Acceptance Criteria
|
||||||
|
+32. Scoped queue/docs invariants are covered by automated tests (not manual-only verification).
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Make robot-mode surgical output first-class
|
||||||
|
For agent workflows, include full stage telemetry and actionable recovery commands.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 15: Update `SyncResult` for robot mode structured output
|
||||||
|
+ /// Per-stage elapsed ms for deterministic performance tracking
|
||||||
|
+ pub stage_timings_ms: std::collections::BTreeMap<String, u64>,
|
||||||
|
+ /// Suggested recovery commands (robot ergonomics)
|
||||||
|
+ pub recovery_actions: Vec<String>,
|
||||||
|
|
||||||
|
@@ Step 14: Update `robot-docs` manifest
|
||||||
|
+Document surgical-specific error codes and `actions` schema for automated recovery.
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can now produce a fully rewritten **iteration 3** plan that merges these into your current structure end-to-end.
|
||||||
212
docs/plan-surgical-sync.feedback-4.md
Normal file
212
docs/plan-surgical-sync.feedback-4.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
1. **Resolve the current contract contradictions (`preflight-only`, `dry-run`, `sync_runs`)**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- Right now constraints conflict: “zero DB writes before commit” vs inserting `sync_runs` during preflight.
|
||||||
|
- This ambiguity will cause implementation drift and flaky acceptance tests.
|
||||||
|
- Splitting control-plane writes from content-plane writes keeps safety guarantees strict while preserving observability.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Design Constraints
|
||||||
|
-6. **Preflight-then-commit**: All remote fetches happen BEFORE any DB writes. If any IID fetch fails (404, network error), the entire operation aborts with zero DB mutations.
|
||||||
|
+6. **Preflight-then-commit (content-plane)**: All remote fetches happen BEFORE any writes to content tables (`issues`, `merge_requests`, `discussions`, `resource_events`, `documents`, `embeddings`).
|
||||||
|
+7. **Control-plane exception**: `sync_runs` / `sync_run_entities` writes are allowed during preflight for observability and crash diagnostics.
|
||||||
|
@@
|
||||||
|
-11. **Preflight-only mode**: `--preflight-only` validates remote entity existence and permissions with zero DB writes.
|
||||||
|
+11. **Preflight-only mode**: `--preflight-only` performs zero content writes; control-plane run-ledger writes are allowed.
|
||||||
|
@@ ### For me to evaluate (functional):
|
||||||
|
-24. **Preflight-only mode** ... no DB mutations beyond the sync_runs ledger entry
|
||||||
|
+24. **Preflight-only mode** ... no content DB mutations; only run-ledger rows may be written
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
2. **Add stale-write protection to avoid TOCTOU regressions during unlocked preflight**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- You intentionally preflight without lock; that’s good for throughput but introduces race risk.
|
||||||
|
- Without a guard, a slower surgical run can overwrite newer data ingested by a concurrent normal sync.
|
||||||
|
- This is a correctness bug under contention, not a nice-to-have.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Design Constraints
|
||||||
|
+12. **Stale-write protection**: Surgical ingest MUST NOT overwrite fresher local rows. If local `updated_at` is newer than the preflight payload’s `updated_at`, skip that entity and record `skipped_stale`.
|
||||||
|
@@ ## Step 7: Create `src/ingestion/surgical.rs`
|
||||||
|
- let labels_created = process_single_issue(conn, config, project_id, issue)?;
|
||||||
|
+ // Skip stale payloads to avoid TOCTOU overwrite after unlocked preflight.
|
||||||
|
+ if is_local_newer_issue(conn, project_id, issue.iid, issue.updated_at)? {
|
||||||
|
+ result.skipped_stale += 1;
|
||||||
|
+ return Ok(result);
|
||||||
|
+ }
|
||||||
|
+ let labels_created = process_single_issue(conn, config, project_id, issue)?;
|
||||||
|
@@
|
||||||
|
+// same guard for MR path
|
||||||
|
@@ ## Step 15: Update `SyncResult`
|
||||||
|
+ /// Entities skipped because local row was newer than preflight payload
|
||||||
|
+ pub skipped_stale: usize,
|
||||||
|
@@ ### Edge cases to verify:
|
||||||
|
+38. **TOCTOU safety**: if a normal sync updates entity after preflight but before ingest, surgical run skips stale payload (no overwrite)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
3. **Make dirty-source scoping exact (do not capture pre-existing rows for same entity)**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- Current “query dirty rows by `source_id` after ingest” can accidentally include older dirty rows for the same entity.
|
||||||
|
- That silently violates strict run scoping and can delete unrelated backlog rows.
|
||||||
|
- You can fix this without adding `origin_run_id` to `dirty_sources` (which you already rejected).
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Step 7: Create `src/ingestion/surgical.rs`
|
||||||
|
- // Collect dirty_source rows for this entity
|
||||||
|
- let mut stmt = conn.prepare(
|
||||||
|
- "SELECT id FROM dirty_sources WHERE source_type = 'issue' AND source_id = ?1"
|
||||||
|
- )?;
|
||||||
|
+ // Capture only rows inserted by THIS call using high-water mark.
|
||||||
|
+ let before_dirty_id: i64 = conn.query_row(
|
||||||
|
+ "SELECT COALESCE(MAX(id), 0) FROM dirty_sources",
|
||||||
|
+ [], |r| r.get(0),
|
||||||
|
+ )?;
|
||||||
|
+ // ... call process_single_issue ...
|
||||||
|
+ let mut stmt = conn.prepare(
|
||||||
|
+ "SELECT id FROM dirty_sources
|
||||||
|
+ WHERE id > ?1 AND source_type = 'issue' AND source_id = ?2"
|
||||||
|
+ )?;
|
||||||
|
@@
|
||||||
|
+ // same pattern for MR
|
||||||
|
@@ ### 1d. Scoping invariant tests
|
||||||
|
+#[test]
|
||||||
|
+fn surgical_docs_scope_ignores_preexisting_dirty_rows_for_same_entity() {
|
||||||
|
+ // pre-insert dirty row for iid=7, then surgical ingest iid=7
|
||||||
|
+ // assert result.dirty_source_ids only contains newly inserted rows
|
||||||
|
+}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
4. **Fix embed-stage leakage when `--no-docs` is used in surgical mode**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- Current design can run global embed even when docs stage is skipped, which may embed unrelated backlog docs.
|
||||||
|
- That breaks the surgical “scope only this run” promise.
|
||||||
|
- This is both correctness and operator-trust critical.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Step 9: Create `run_sync_surgical`
|
||||||
|
- if !options.no_embed {
|
||||||
|
+ // Surgical embed only runs when surgical docs actually regenerated docs in this run.
|
||||||
|
+ if !options.no_embed && !options.no_docs && result.documents_regenerated > 0 {
|
||||||
|
@@ ## Step 4: Wire new fields in `handle_sync_cmd`
|
||||||
|
+ if options.is_surgical() && options.no_docs && !options.no_embed {
|
||||||
|
+ return Err(Box::new(LoreError::Other(
|
||||||
|
+ "In surgical mode, --no-docs requires --no-embed (to preserve scoping guarantees)".to_string()
|
||||||
|
+ )));
|
||||||
|
+ }
|
||||||
|
@@ ### For me to evaluate
|
||||||
|
+39. **No embed leakage**: `sync --issue X --no-docs` never embeds unrelated unembedded docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
5. **Add queue-failure hygiene so scoped jobs do not leak forever**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- Scoped drains prevent accidental processing, but failed runs can strand pending jobs permanently.
|
||||||
|
- You need explicit terminalization (`aborted`) and optional replay mechanics.
|
||||||
|
- Otherwise queue bloat and confusing diagnostics accumulate.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Step 8a: Add `sync_runs` table migration
|
||||||
|
+ALTER TABLE dependent_queue ADD COLUMN aborted_reason TEXT;
|
||||||
|
+-- status domain now includes: pending, claimed, done, failed, aborted
|
||||||
|
@@ ## Step 9: run_sync_surgical failure paths
|
||||||
|
+// On run failure/cancel:
|
||||||
|
+conn.execute(
|
||||||
|
+ "UPDATE dependent_queue
|
||||||
|
+ SET status='aborted', aborted_reason=?1
|
||||||
|
+ WHERE project_id=?2 AND scope_run_id=?3 AND status='pending'",
|
||||||
|
+ rusqlite::params![failure_summary, project_id, run_id],
|
||||||
|
+)?;
|
||||||
|
@@ ## Acceptance Criteria
|
||||||
|
+40. **No stranded scoped jobs**: failed surgical runs leave no `pending` rows for their `scope_run_id`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
6. **Persist per-entity lifecycle (`sync_run_entities`) for real observability and deterministic retry**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- `sync_runs` alone gives aggregate counters but not which IID failed at which stage.
|
||||||
|
- Per-entity records make retries deterministic and robot output far more useful.
|
||||||
|
- This is the missing piece for your stated “deterministic retry decisions.”
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Step 8a: Add `sync_runs` table migration
|
||||||
|
+CREATE TABLE IF NOT EXISTS sync_run_entities (
|
||||||
|
+ id INTEGER PRIMARY KEY,
|
||||||
|
+ run_id TEXT NOT NULL REFERENCES sync_runs(run_id),
|
||||||
|
+ entity_type TEXT NOT NULL CHECK(entity_type IN ('issue','merge_request')),
|
||||||
|
+ iid INTEGER NOT NULL,
|
||||||
|
+ stage TEXT NOT NULL,
|
||||||
|
+ status TEXT NOT NULL CHECK(status IN ('ok','failed','skipped_stale')),
|
||||||
|
+ error_code TEXT,
|
||||||
|
+ error_message TEXT,
|
||||||
|
+ updated_at INTEGER NOT NULL
|
||||||
|
+);
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_sync_run_entities_run ON sync_run_entities(run_id, entity_type, iid);
|
||||||
|
@@ ## Step 15: Update `SyncResult`
|
||||||
|
+ pub failed_iids: Vec<(String, u64)>,
|
||||||
|
+ pub skipped_stale_iids: Vec<(String, u64)>,
|
||||||
|
@@ ## CLI Interface
|
||||||
|
+lore --robot sync-runs --run-id <id>
|
||||||
|
+lore --robot sync-runs --run-id <id> --retry-failed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
7. **Use explicit error type for surgical preflight failures (not `LoreError::Other`)**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- `Other(String)` loses machine semantics, weakens robot mode, and leads to bad exit-code behavior.
|
||||||
|
- A typed error preserves structured failures and enables actionable recovery commands.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Step 9: run_sync_surgical
|
||||||
|
- return Err(LoreError::Other(
|
||||||
|
- format!("Surgical preflight failed for {} of {} IIDs: {}", ...)
|
||||||
|
- ).into());
|
||||||
|
+ return Err(LoreError::SurgicalPreflightFailed {
|
||||||
|
+ run_id: run_id.to_string(),
|
||||||
|
+ total: total_items,
|
||||||
|
+ failures: preflight.failures.clone(),
|
||||||
|
+ }.into());
|
||||||
|
@@ ## Step 15: Update `SyncResult`
|
||||||
|
+ /// Machine-actionable error summary for robot mode
|
||||||
|
+ pub error_code: Option<String>,
|
||||||
|
@@ ## Acceptance Criteria
|
||||||
|
+41. **Typed failure**: preflight failures serialize structured errors (not generic `Other`) with machine-usable codes/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
8. **Strengthen tests for rollback, contention, and stale-skip guarantees**
|
||||||
|
|
||||||
|
Why this improves the plan:
|
||||||
|
- Current tests cover many happy-paths and scoping invariants, but key race/rollback behaviors are still under-tested.
|
||||||
|
- These are exactly where regressions will appear first in production.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Step 1: TDD — Write Failing Tests First
|
||||||
|
+### 1f. Transactional rollback + TOCTOU tests
|
||||||
|
+1. `preflight_success_then_ingest_failure_rolls_back_all_content_writes`
|
||||||
|
+2. `stale_payload_is_skipped_when_local_updated_at_is_newer`
|
||||||
|
+3. `failed_run_aborts_pending_scoped_jobs`
|
||||||
|
+4. `surgical_no_docs_requires_no_embed`
|
||||||
|
@@ ### Automated scoping invariants
|
||||||
|
-38. **Scoped queue/docs invariants are enforced by automated tests**
|
||||||
|
+42. **Rollback and race invariants are enforced by automated tests** (no partial writes on ingest failure, no stale overwrite)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
These eight revisions keep your core approach intact, avoid your explicitly rejected ideas, and close the biggest correctness/operability gaps before implementation.
|
||||||
130
docs/plan-surgical-sync.feedback-5.md
Normal file
130
docs/plan-surgical-sync.feedback-5.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
**Critical Gaps In Current Plan**
|
||||||
|
1. `dirty_sources` scoping is based on `id`, but `dirty_sources` has no `id` column and uses `(source_type, source_id)` UPSERT semantics.
|
||||||
|
2. Plan assumes a new `dependent_queue` with `status`, but current code uses `pending_dependent_fetches` (delete-on-complete), so queue-scoping design conflicts with existing invariants.
|
||||||
|
3. Constraint 6 says all remote fetches happen before any content writes, but the proposed surgical flow fetches discussions/events/diffs after ingest writes.
|
||||||
|
4. `sync_runs` is already an existing table and already used by `SyncRunRecorder`; the plan currently treats it like a new table.
|
||||||
|
|
||||||
|
**Best Revisions**
|
||||||
|
|
||||||
|
1. **Fix dirty-source scoping to match real schema (queued-at watermark, not `id` high-water).**
|
||||||
|
Why this is better: This removes a correctness bug and makes same-entity re-ingest deterministic under UPSERT behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
-2. Dirty queue scoping: ... capture MAX(id) FROM dirty_sources ... run_generate_docs_for_dirty_ids ...
|
||||||
|
+2. Dirty queue scoping: `dirty_sources` is keyed by `(source_type, source_id)` and updated via UPSERT.
|
||||||
|
+ Surgical scoping MUST use:
|
||||||
|
+ 1) a run-level `run_dirty_floor_ms` captured before surgical ingest, and
|
||||||
|
+ 2) explicit touched source keys from ingest (`(source_type, source_id)`).
|
||||||
|
+ Surgical docs MUST call a scoped API (e.g. `run_generate_docs_for_sources`) and MUST NOT drain global dirty queue.
|
||||||
|
@@ Step 9a
|
||||||
|
-pub fn run_generate_docs_for_dirty_ids(config: &Config, dirty_source_ids: &[i64]) -> Result<GenerateDocsResult>
|
||||||
|
+pub fn run_generate_docs_for_sources(config: &Config, sources: &[(SourceType, i64)]) -> Result<GenerateDocsResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Bypass shared dependent queue in surgical mode; run dependents inline per target.**
|
||||||
|
Why this is better: Avoids queue migration churn, avoids run-scope conflicts with existing unique constraints, and removes orphan-job hygiene complexity entirely.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
-4. Dependent queue scoping: ... scope_run_id indexed column on dependent_queue ...
|
||||||
|
+4. Surgical dependent execution: surgical mode MUST bypass `pending_dependent_fetches`.
|
||||||
|
+ Dependents (resource_events, mr_closes_issues, mr_diffs) run inline for targeted entities only.
|
||||||
|
+ Global queue remains for normal sync only.
|
||||||
|
@@ Design Constraints
|
||||||
|
-14. Queue failure hygiene: ... pending scoped jobs ... terminalized to aborted ...
|
||||||
|
+14. Surgical failure hygiene: surgical mode MUST leave no queue artifacts because it does not enqueue dependent jobs.
|
||||||
|
@@ Step 9b / 9c / Step 13
|
||||||
|
-Implement scoped drain helpers and enqueue_job scope_run_id plumbing
|
||||||
|
+Replace with direct per-entity helpers in ingestion layer:
|
||||||
|
+ - sync_issue_resource_events_direct(...)
|
||||||
|
+ - sync_mr_resource_events_direct(...)
|
||||||
|
+ - sync_mr_closes_issues_direct(...)
|
||||||
|
+ - sync_mr_diffs_direct(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Clarify atomicity contract to “primary-entity atomicity” (remove contradiction).**
|
||||||
|
Why this is better: Keeps strong zero-write guarantees for missing IIDs while matching practical staged pipeline behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
-6. Preflight-then-commit (content-plane): All remote fetches happen BEFORE any writes to content tables ...
|
||||||
|
+6. Primary-entity atomicity: all requested issue/MR payload fetches complete before first content write.
|
||||||
|
+ If any primary IID fetch fails, primary ingest does zero content writes.
|
||||||
|
+ Dependent stages (discussions/events/diffs/closes) are post-ingest and best-effort, with structured per-stage failure reporting.
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Extend existing `sync_runs` schema instead of redefining it.**
|
||||||
|
Why this is better: Preserves compatibility with current `SyncRunRecorder`, `sync_status`, and existing historical data.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 8a
|
||||||
|
-Add `sync_runs` table migration (CREATE TABLE sync_runs ...)
|
||||||
|
+Add migration 027 to extend existing `sync_runs` table:
|
||||||
|
+ - ADD COLUMN mode TEXT NULL -- 'standard' | 'surgical'
|
||||||
|
+ - ADD COLUMN phase TEXT NULL -- preflight|ingest|dependents|docs|embed|done|failed
|
||||||
|
+ - ADD COLUMN surgical_summary_json TEXT NULL
|
||||||
|
+Reuse `SyncRunRecorder` row lifecycle; do not introduce a parallel run-ledger model.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Strengthen TOCTOU stale protection for equal timestamps.**
|
||||||
|
Why this is better: Prevents regressions when `updated_at` is equal but a fresher local fetch already happened.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
-13. ... If local `updated_at` is newer than preflight payload `updated_at`, skip ...
|
||||||
|
+13. ... Skip stale when:
|
||||||
|
+ a) local.updated_at > payload.updated_at, OR
|
||||||
|
+ b) local.updated_at == payload.updated_at AND local.last_seen_at > preflight_started_at_ms.
|
||||||
|
+ This prevents equal-timestamp regressions under concurrent sync.
|
||||||
|
@@ Step 1f tests
|
||||||
|
+Add test: `equal_updated_at_but_newer_last_seen_is_skipped`.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Shrink lock window further: release `sync` lock before embed; use dedicated embed lock.**
|
||||||
|
Why this is better: Prevents long embedding from blocking unrelated syncs and avoids concurrent embed writers.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Design Constraints
|
||||||
|
-11. Lock ... held through all mutation stages.
|
||||||
|
+11. Lock ... held through ingest/dependents/docs only.
|
||||||
|
+ Release `AppLock("sync")` before embed.
|
||||||
|
+ Embed stage uses `AppLock("embed")` for single-flight embedding writes.
|
||||||
|
@@ Step 9
|
||||||
|
-Embed runs inside the same sync lock window
|
||||||
|
+Embed runs after sync lock release, under dedicated embed lock
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add the missing `sync-runs` robot read path (the plan references it but doesn’t define it).**
|
||||||
|
Why this is better: Makes durable run-state actually useful for recovery automation and observability.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Step 14 (new)
|
||||||
|
+## Step 14a: Add `sync-runs` read command
|
||||||
|
+
|
||||||
|
+CLI:
|
||||||
|
+ lore --robot sync-runs --limit 20
|
||||||
|
+ lore --robot sync-runs --run-id <id>
|
||||||
|
+ lore --robot sync-runs --state failed
|
||||||
|
+
|
||||||
|
+Robot response fields:
|
||||||
|
+ run_id, mode, phase, status, started_at, finished_at, counters, failures, suggested_retry_command
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Add URL-native surgical targets (`--issue-url`, `--mr-url`) with project inference.**
|
||||||
|
Why this is better: Much more agent-friendly and reduces project-resolution errors from copy/paste workflows.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ CLI Interface
|
||||||
|
lore sync --issue 123 --issue 456 -p myproject
|
||||||
|
+lore sync --issue-url https://gitlab.example.com/group/proj/-/issues/123
|
||||||
|
+lore sync --mr-url https://gitlab.example.com/group/proj/-/merge_requests/789
|
||||||
|
@@ Step 2
|
||||||
|
+Add repeatable flags:
|
||||||
|
+ --issue-url <url>
|
||||||
|
+ --mr-url <url>
|
||||||
|
+Parse URL into (project_path, iid). If all targets are URL-derived and same project, `-p` is optional.
|
||||||
|
+If mixed projects are provided in one command, reject with clear error.
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated patched version of your plan (iteration 5 draft) with these revisions already merged.
|
||||||
152
docs/plan-surgical-sync.feedback-6.md
Normal file
152
docs/plan-surgical-sync.feedback-6.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
Highest-impact revisions after reviewing your v5 plan:
|
||||||
|
|
||||||
|
1. **Fix a real scoping hole: embed can still process unrelated docs**
|
||||||
|
Rationale: Current plan assumes scoped docs implies scoped embed, but that only holds while no other run creates unembedded docs. You explicitly release sync lock before embed, so another sync can enqueue/regenerate docs in between, and `run_embed` may embed unrelated backlog. This breaks surgical isolation and can hide backlog debt.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Design Constraints
|
||||||
|
-3. Embed scoping: Embedding runs only for documents regenerated by this surgical run. Because `run_embed` processes only unembedded docs, scoping is automatic IF docs are scoped correctly...
|
||||||
|
+3. Embed scoping: Embedding MUST be explicitly scoped to documents regenerated by this surgical run.
|
||||||
|
+ `run_generate_docs_for_sources` returns regenerated `document_ids`; surgical mode calls
|
||||||
|
+ `run_embed_for_document_ids(document_ids)` and never global `run_embed`.
|
||||||
|
+ This remains true even after lock release and under concurrent normal sync activity.
|
||||||
|
@@ Step 9a: Implement `run_generate_docs_for_sources`
|
||||||
|
-pub fn run_generate_docs_for_sources(...) -> Result<GenerateDocsResult> {
|
||||||
|
+pub fn run_generate_docs_for_sources(...) -> Result<GenerateDocsResult> {
|
||||||
|
+ // Return regenerated document IDs for scoped embedding.
|
||||||
|
+ // GenerateDocsResult { regenerated, errored, regenerated_document_ids: Vec<i64> }
|
||||||
|
@@ Step 9: Embed stage
|
||||||
|
- match run_embed(config, false, false, None, signal).await {
|
||||||
|
+ match run_embed_for_document_ids(config, &result.regenerated_document_ids, signal).await {
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Make run-ledger lifecycle actually durable (and consistent with your own constraint 10)**
|
||||||
|
Rationale: Plan text says “reuse `SyncRunRecorder`”, but Step 9 writes raw SQL directly. That creates lifecycle drift, missing heartbeats, and inconsistent failure handling as code evolves.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Design Constraints
|
||||||
|
-10. Durable run state: ... Reuses `SyncRunRecorder` row lifecycle ...
|
||||||
|
+10. Durable run state: surgical sync MUST use `SyncRunRecorder` end-to-end (no ad-hoc SQL updates).
|
||||||
|
+ Add recorder APIs for `set_mode`, `set_phase`, `set_counters`, `finish_succeeded`,
|
||||||
|
+ `finish_failed`, `finish_cancelled`, and periodic `heartbeat`.
|
||||||
|
@@ Step 9: Create `run_sync_surgical`
|
||||||
|
- conn.execute("INSERT INTO sync_runs ...")
|
||||||
|
- conn.execute("UPDATE sync_runs SET phase = ...")
|
||||||
|
+ let mut recorder = SyncRunRecorder::start_surgical(...)?;
|
||||||
|
+ recorder.set_phase("preflight")?;
|
||||||
|
+ recorder.heartbeat_if_due()?;
|
||||||
|
+ recorder.set_phase("ingest")?;
|
||||||
|
+ ...
|
||||||
|
+ recorder.finish_succeeded_with_warnings(...)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add explicit `cancelled` terminal state**
|
||||||
|
Rationale: Current early cancellation branches return `Ok(result)` without guaranteed run-row finalization. That leaves misleading `running` rows and weak crash diagnostics.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Design Constraints
|
||||||
|
+15. Cancellation semantics: If shutdown is observed after run start, phase is set to `cancelled`,
|
||||||
|
+ status is `cancelled`, `finished_at` is written, and lock is released before return.
|
||||||
|
@@ Step 8a migration
|
||||||
|
+ALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
+ALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;
|
||||||
|
@@ Acceptance Criteria
|
||||||
|
+47. Cancellation durability: Ctrl+C during surgical sync records `status='cancelled'`,
|
||||||
|
+ `phase='cancelled'`, and `finished_at` in `sync_runs`.
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Reduce lock contention further by separating dependent fetch and dependent write**
|
||||||
|
Rationale: You currently hold lock through network-heavy dependent stages. That maximizes contention and increases lock timeout risk. Better: fetch dependents unlocked, write in short locked transactions with per-entity freshness guards.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Design Constraints
|
||||||
|
-11. Lock window minimization: ... held through ingest, dependents, and docs stages.
|
||||||
|
+11. Lock window minimization: lock is held only for DB mutation windows.
|
||||||
|
+ Dependents run in two phases:
|
||||||
|
+ (a) fetch from GitLab without lock,
|
||||||
|
+ (b) write results under lock in short transactions.
|
||||||
|
+ Apply per-entity freshness checks before dependent writes.
|
||||||
|
@@ Step 9: Dependent stages
|
||||||
|
- // All dependents run INLINE per-entity ... while lock is held
|
||||||
|
+ // Dependents fetch outside lock, then write under lock with CAS-style watermark guards.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Introduce stage timeout budgets to prevent hung surgical runs**
|
||||||
|
Rationale: A single slow GitLab endpoint can stall the whole run and hold resources too long. Timeout budgets plus per-entity failure recording keep the run bounded and predictable.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Design Constraints
|
||||||
|
+16. Stage timeout budgets: each dependent fetch has a per-entity timeout and a global stage budget.
|
||||||
|
+ Timed-out entities are recorded in `entity_failures` with code `TIMEOUT` and run continues best-effort.
|
||||||
|
@@ Step 9 notes
|
||||||
|
+ - Wrap dependent network calls with `tokio::time::timeout`.
|
||||||
|
+ - Add config knobs:
|
||||||
|
+ `sync.surgical_entity_timeout_seconds` (default 20),
|
||||||
|
+ `sync.surgical_dependents_budget_seconds` (default 120).
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add payload integrity checks (project mismatch hard-fail)**
|
||||||
|
Rationale: Surgical mode is precision tooling. If API/proxy misconfiguration returns payloads from wrong project, you should fail preflight loudly, not trust downstream assumptions.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Step 7: preflight_fetch
|
||||||
|
+ // Integrity check: payload.project_id must equal requested gitlab_project_id.
|
||||||
|
+ // On mismatch, record EntityFailure { code: "PROJECT_MISMATCH", stage: "fetch" }.
|
||||||
|
@@ Step 9d: error codes
|
||||||
|
+PROJECT_MISMATCH -> usage/config data integrity failure (typed, machine-readable)
|
||||||
|
@@ Acceptance Criteria
|
||||||
|
+48. Project integrity: payloads with unexpected `project_id` are rejected in preflight
|
||||||
|
+ and produce zero content writes.
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Upgrade robot output from aggregate-only to per-entity lifecycle**
|
||||||
|
Rationale: `entity_failures` alone is not enough for robust automation. Agents need a complete entity outcome map (fetched, ingested, stale-skipped, dependent failures) to retry deterministically.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Step 15: Update `SyncResult`
|
||||||
|
+pub struct EntityOutcome {
|
||||||
|
+ pub entity_type: String,
|
||||||
|
+ pub iid: u64,
|
||||||
|
+ pub fetched: bool,
|
||||||
|
+ pub ingested: bool,
|
||||||
|
+ pub stale_skipped: bool,
|
||||||
|
+ pub dependent_failures: Vec<EntityFailure>,
|
||||||
|
+}
|
||||||
|
@@
|
||||||
|
+pub entity_outcomes: Vec<EntityOutcome>,
|
||||||
|
+pub completion_status: String, // succeeded | succeeded_with_warnings | failed | cancelled
|
||||||
|
@@ Robot mode
|
||||||
|
- enables agents to detect partial failures via `entity_failures`
|
||||||
|
+ enables deterministic, per-IID retry and richer UI messaging.
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Index `sync_runs` for real observability at scale**
|
||||||
|
Rationale: You’re adding mode/phase/counters and then querying recent surgical runs. Without indexes, this degrades as run history grows.
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Step 8a migration
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started
|
||||||
|
+ ON sync_runs(mode, started_at DESC);
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started
|
||||||
|
+ ON sync_runs(status, phase, started_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Add tests specifically for the new failure-prone paths**
|
||||||
|
Rationale: Current tests are strong on ingest and scoping, but still miss new high-risk runtime behavior (cancel state, timeout handling, scoped embed under concurrency).
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ Step 1f tests
|
||||||
|
+#[tokio::test]
|
||||||
|
+async fn cancellation_marks_sync_run_cancelled() { ... }
|
||||||
|
+
|
||||||
|
+#[tokio::test]
|
||||||
|
+async fn dependent_timeout_records_entity_failure_and_continues() { ... }
|
||||||
|
+
|
||||||
|
+#[tokio::test]
|
||||||
|
+async fn scoped_embed_does_not_embed_unrelated_docs_created_after_docs_stage() { ... }
|
||||||
|
@@ Acceptance Criteria
|
||||||
|
+49. Scoped embed isolation under concurrency is verified by automated test.
|
||||||
|
+50. Timeout path is verified (TIMEOUT code + continued processing).
|
||||||
|
```
|
||||||
|
|
||||||
|
These revisions keep your core direction intact, avoid every rejected recommendation, and materially improve correctness under concurrency, operational observability, and agent automation quality.
|
||||||
2240
docs/plan-surgical-sync.md
Normal file
2240
docs/plan-surgical-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
131
docs/prd-per-note-search.feedback-6.md
Normal file
131
docs/prd-per-note-search.feedback-6.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
1. **Make immutable identity usable now (`--author-id`)**
|
||||||
|
Why: The plan captures `author_id` but intentionally defers using it, so the core longitudinal-analysis problem is only half-fixed.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 1: `lore notes` Command / Work Chunk 1A
|
||||||
|
pub struct NoteListFilters<'a> {
|
||||||
|
+ pub author_id: Option<i64>, // immutable identity filter
|
||||||
|
@@
|
||||||
|
- pub author: Option<&'a str>, // case-insensitive match via COLLATE NOCASE
|
||||||
|
+ pub author: Option<&'a str>, // display-name filter
|
||||||
|
+ // If both author and author_id are provided, apply both (AND) for precision.
|
||||||
|
}
|
||||||
|
@@
|
||||||
|
Filter mappings:
|
||||||
|
+ - `author_id`: `n.author_id = ?` (exact immutable identity)
|
||||||
|
- `author`: strip `@` prefix, `n.author_username = ? COLLATE NOCASE`
|
||||||
|
@@ Phase 1 / Work Chunk 1B (CLI)
|
||||||
|
+ /// Filter by immutable author id
|
||||||
|
+ #[arg(long = "author-id", help_heading = "Filters")]
|
||||||
|
+ pub author_id: Option<i64>,
|
||||||
|
@@ Phase 2 / Work Chunk 2F
|
||||||
|
+ Add `--author-id` support to `lore search` filtering for note documents.
|
||||||
|
@@ Phase 1 / Work Chunk 1E
|
||||||
|
+ CREATE INDEX IF NOT EXISTS idx_notes_project_author_id_created
|
||||||
|
+ ON notes(project_id, author_id, created_at DESC, id DESC)
|
||||||
|
+ WHERE is_system = 0 AND author_id IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Fix document staleness on username changes**
|
||||||
|
Why: Current plan says username changes are “not semantic,” but note documents include username in content/title, so docs go stale/inconsistent.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 0D: Immutable Author Identity Capture
|
||||||
|
- Assert: changed_semantics = false (username change is not a semantic change for documents)
|
||||||
|
+ Assert: changed_semantics = true (username affects note document content/title)
|
||||||
|
@@ Work Chunk 0A: semantic-change detection
|
||||||
|
- old_body != body || old_note_type != note_type || ...
|
||||||
|
+ old_body != body || old_note_type != note_type || ...
|
||||||
|
+ || old_author_username != author_username
|
||||||
|
@@ Work Chunk 2C: Note Document Extractor header
|
||||||
|
author: @{author}
|
||||||
|
+ author_id: {author_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Replace `last_seen_at` sweep marker with monotonic `sync_run_id`**
|
||||||
|
Why: Timestamp markers are vulnerable to clock skew and concurrent runs; run IDs are deterministic and safer.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 0: Stable Note Identity
|
||||||
|
+ ### Work Chunk 0E: Monotonic Run Marker
|
||||||
|
+ Add `sync_runs` table and `notes.last_seen_run_id`.
|
||||||
|
+ Ingest assigns one run_id per sync transaction.
|
||||||
|
+ Upsert sets `last_seen_run_id = current_run_id`.
|
||||||
|
+ Sweep condition becomes `last_seen_run_id < current_run_id` (when fetch_complete=true).
|
||||||
|
@@ Work Chunk 0C
|
||||||
|
- fetch_complete + last_seen_at-based sweep
|
||||||
|
+ fetch_complete + run_id-based sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Materialize stale-note set once during sweep**
|
||||||
|
Why: Current set-based SQL still re-runs the stale subquery 3 times; materializing once improves performance and guarantees identical deletion set.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 0B: Immediate Deletion Propagation
|
||||||
|
- DELETE FROM documents ... IN (SELECT id FROM notes WHERE ...);
|
||||||
|
- DELETE FROM dirty_sources ... IN (SELECT id FROM notes WHERE ...);
|
||||||
|
- DELETE FROM notes WHERE ...;
|
||||||
|
+ CREATE TEMP TABLE _stale_note_ids AS
|
||||||
|
+ SELECT id, is_system FROM notes WHERE discussion_id = ? AND last_seen_run_id < ?;
|
||||||
|
+ DELETE FROM documents
|
||||||
|
+ WHERE source_type='note' AND source_id IN (SELECT id FROM _stale_note_ids WHERE is_system=0);
|
||||||
|
+ DELETE FROM dirty_sources
|
||||||
|
+ WHERE source_type='note' AND source_id IN (SELECT id FROM _stale_note_ids WHERE is_system=0);
|
||||||
|
+ DELETE FROM notes WHERE id IN (SELECT id FROM _stale_note_ids);
|
||||||
|
+ DROP TABLE _stale_note_ids;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Move historical note backfill out of migration into resumable runtime job**
|
||||||
|
Why: Data-heavy migration can block startup and is harder to resume/recover on large DBs.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 2H
|
||||||
|
- Backfill Existing Notes After Upgrade (Migration 024)
|
||||||
|
+ Backfill Existing Notes After Upgrade (Resumable Runtime Backfill)
|
||||||
|
@@
|
||||||
|
- Files: `migrations/024_note_dirty_backfill.sql`, `src/core/db.rs`
|
||||||
|
+ Files: `src/documents/backfill.rs`, `src/cli/commands/generate_docs.rs`
|
||||||
|
@@
|
||||||
|
- INSERT INTO dirty_sources ... SELECT ... FROM notes ...
|
||||||
|
+ Introduce batched backfill API:
|
||||||
|
+ `enqueue_missing_note_documents(batch_size: usize) -> BackfillProgress`
|
||||||
|
+ invoked from `generate-docs`/`sync` until complete, resumable across runs.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add streaming path for large `jsonl`/`csv` note exports**
|
||||||
|
Why: Current `query_notes` materializes full result set in memory; streaming improves scalability and latency.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1A
|
||||||
|
+ Add `query_notes_stream(conn, filters, row_handler)` for forward-only row iteration.
|
||||||
|
@@ Work Chunk 1C
|
||||||
|
- print_list_notes_jsonl(&result)
|
||||||
|
- print_list_notes_csv(&result)
|
||||||
|
+ print_list_notes_jsonl_stream(config, filters)
|
||||||
|
+ print_list_notes_csv_stream(config, filters)
|
||||||
|
+ (table/json keep counted buffered path)
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add index for path-centric note queries**
|
||||||
|
Why: `--path` + project/date queries are a stated hot path and not fully covered by current proposed indexes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1E: Composite Query Index
|
||||||
|
+ CREATE INDEX IF NOT EXISTS idx_notes_project_path_created
|
||||||
|
+ ON notes(project_id, position_new_path, created_at DESC, id DESC)
|
||||||
|
+ WHERE is_system = 0 AND position_new_path IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Add property/invariant tests (not only examples)**
|
||||||
|
Why: This feature touches ingestion identity, sweeping, deletion propagation, and document regeneration; randomized invariants will catch subtle regressions.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Verification Checklist
|
||||||
|
+ Add property tests (proptest):
|
||||||
|
+ - stable local IDs across randomized re-sync orderings
|
||||||
|
+ - no orphan `documents(source_type='note')` after randomized deletions/sweeps
|
||||||
|
+ - partial-fetch runs never reduce note count
|
||||||
|
+ - repeated full rebuild converges (fixed-point idempotence)
|
||||||
|
```
|
||||||
|
|
||||||
|
These revisions keep your existing direction, avoid all rejected items, and materially improve correctness, scale behavior, and long-term maintainability.
|
||||||
2518
docs/prd-per-note-search.md
Normal file
2518
docs/prd-per-note-search.md
Normal file
File diff suppressed because it is too large
Load Diff
541
docs/user-journeys.md
Normal file
541
docs/user-journeys.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Lore CLI User Journeys
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Map realistic workflows for both human users and AI agents to identify gaps in the command surface and optimization opportunities. Each journey starts with a **problem** and traces the commands needed to reach a **resolution**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Human User Flows
|
||||||
|
|
||||||
|
### H1. Morning Standup Prep
|
||||||
|
|
||||||
|
**Problem:** "What happened since yesterday? I need to know what moved before standup."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore sync -q # Refresh data (quiet, no noise)
|
||||||
|
lore issues -s opened --since 1d # Issues that changed overnight
|
||||||
|
lore mrs -s opened --since 1d # MRs that moved
|
||||||
|
lore who @me # My current workload snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No single "activity feed" command. User runs 3 queries to get what should be one view. No `--since 1d` shorthand for "since yesterday." No `@me` alias for the authenticated user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H2. Sprint Planning: What's Ready to Pick Up?
|
||||||
|
|
||||||
|
**Problem:** "We're planning the next sprint. What's open, unassigned, and actionable?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore issues -s opened -p myproject # All open issues
|
||||||
|
lore issues -s opened -l "ready" # Issues labeled ready
|
||||||
|
lore issues -s opened --has-due # Issues with deadlines approaching
|
||||||
|
lore count issues -p myproject # How many total?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to filter by "unassigned" issues (missing `--no-assignee` flag). No way to sort by due date. No way to see priority/weight. Can't combine filters like "opened AND no assignee AND has due date."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H3. Investigating a Production Incident
|
||||||
|
|
||||||
|
**Problem:** "Deploy broke prod. I need the full timeline of what changed around the deploy."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore sync -q # Get latest
|
||||||
|
lore timeline "deploy" --since 7d # What happened around deploys
|
||||||
|
lore search "deploy" --type mr # MRs mentioning deploy
|
||||||
|
lore mrs 456 # Inspect the suspicious MR
|
||||||
|
lore who --overlap src/deploy/ # Who else touches deploy code
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Timeline is keyword-based, not event-based. Can't filter by "MRs merged in the last 24 hours" directly. No way to see which MRs were merged between two dates (release diff). Would benefit from `lore mrs -s merged --since 1d`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H4. Preparing to Review Someone's MR
|
||||||
|
|
||||||
|
**Problem:** "I was assigned to review MR !789. I need context before diving in."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore mrs 789 # Read the MR description + discussions
|
||||||
|
lore mrs 789 -o # Open in browser for the actual diff
|
||||||
|
lore who src/features/auth/ # Who are the experts in this area?
|
||||||
|
lore search "auth refactor" --type issue # Related issues for background
|
||||||
|
lore timeline "authentication" # History of auth changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to see the file list touched by an MR from the CLI (data is stored in `mr_file_changes` but not surfaced). No way to link an MR back to its closing issue(s) from the MR detail view. The cross-reference data exists in `entity_references` but isn't shown in `mrs <iid>` output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H5. Onboarding to an Unfamiliar Code Area
|
||||||
|
|
||||||
|
**Problem:** "I'm new to the team and need to understand how the billing module works."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore search "billing" -n 20 # What exists about billing?
|
||||||
|
lore who src/billing/ # Who knows billing best?
|
||||||
|
lore timeline "billing" --depth 2 # History of billing changes
|
||||||
|
lore mrs -s merged -l billing --since 6m # Recent merged billing work
|
||||||
|
lore issues -s opened -l billing # Outstanding billing issues
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to get a "module overview" in one command. The search spans issues, MRs, and discussions but doesn't summarize by category. No way to see the most-discussed or most-referenced entities (high-signal items for understanding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H6. Finding the Right Reviewer for My PR
|
||||||
|
|
||||||
|
**Problem:** "I'm about to submit a PR touching auth and payments. Who should review?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who src/features/auth/ # Auth experts
|
||||||
|
lore who src/features/payments/ # Payment experts
|
||||||
|
lore who @candidate1 # Check candidate1's workload
|
||||||
|
lore who @candidate2 # Check candidate2's workload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to query multiple paths at once (`lore who src/auth/ src/payments/`). No way to find the intersection of expertise. No workload-aware recommendation ("who knows this AND has bandwidth"). Four separate commands for what should be one decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H7. Understanding Why a Feature Was Built This Way
|
||||||
|
|
||||||
|
**Problem:** "This code is weird. Why was it implemented like this? What was the original discussion?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore search "feature-name rationale" # Search for decision context
|
||||||
|
lore timeline "feature-name" --depth 2 # Full history with cross-refs
|
||||||
|
lore issues 234 # Read the original issue
|
||||||
|
lore mrs 567 # Read the implementation MR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to search within a specific issue's or MR's discussion notes. The search covers documents (titles + descriptions) but per-note search isn't available yet (PRD exists). No way to navigate "issue 234 was closed by MR 567" without manually knowing both IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H8. Checking Team Workload Before Assigning Work
|
||||||
|
|
||||||
|
**Problem:** "I need to assign this urgent bug. Who has the least on their plate?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who @alice # Alice's workload
|
||||||
|
lore who @bob # Bob's workload
|
||||||
|
lore who @carol # Carol's workload
|
||||||
|
lore who @dave # Dave's workload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No team-level workload view. Must query each person individually. No way to list "all assignees and their open issue counts." No concept of a team roster. Would benefit from `lore who --team` or `lore workload`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H9. Preparing Release Notes
|
||||||
|
|
||||||
|
**Problem:** "We're cutting a release. I need to summarize what's in this version."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore mrs -s merged --since 2w -p myproject # MRs merged since last release
|
||||||
|
lore issues -s closed --since 2w -p myproject # Issues closed since last release
|
||||||
|
lore mrs -s merged -l feature --since 2w # Feature MRs specifically
|
||||||
|
lore mrs -s merged -l bugfix --since 2w # Bugfix MRs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to filter by milestone (for version-based releases). Wait -- `issues` has `-m` for milestone but `mrs` does not. No changelog generation. No "what closed between tag A and tag B." No grouping by label for release note categories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H10. Finding and Closing Stale Issues
|
||||||
|
|
||||||
|
**Problem:** "Our backlog is bloated. Which issues haven't been touched in months?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore issues -s opened --sort updated --asc -n 50 # Oldest-updated first
|
||||||
|
# Then manually inspect each one...
|
||||||
|
lore issues 42 # Is this still relevant?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No `--before` or `--updated-before` filter (only `--since` exists). Can sort ascending but can't filter "not updated in 90 days." No staleness indicator. No bulk operations concept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H11. Understanding a Bug's Full History
|
||||||
|
|
||||||
|
**Problem:** "Bug #321 keeps getting reopened. I need to understand its entire lifecycle."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore issues 321 # Read the issue
|
||||||
|
lore timeline "bug-keyword" -p myproject # Try to find timeline events
|
||||||
|
# But timeline is keyword-based, not entity-based...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to get a timeline for a specific entity by IID. `lore timeline` requires a keyword query, not an entity reference. Would benefit from `lore timeline --issue 321` or `lore timeline --mr 456` to get the event history of a specific entity directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H12. Identifying Who to Ask About Failing Tests
|
||||||
|
|
||||||
|
**Problem:** "CI tests are failing in `src/lib/parser.rs`. Who last touched this?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who src/lib/parser.rs # Expert lookup
|
||||||
|
lore who --overlap src/lib/parser.rs # Who else has touched it
|
||||||
|
lore search "parser" --type mr --since 2w # Recent MRs touching parser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Expert mode uses DiffNote analysis (code review comments), not actual file change tracking. The `mr_file_changes` table has the real data but `who` doesn't use it for attribution. Could be much more accurate with file-change-based expertise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H13. Tracking a Feature Across Multiple MRs
|
||||||
|
|
||||||
|
**Problem:** "The 'dark mode' feature spans 5 MRs. I need to see them all together."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore mrs -l dark-mode # MRs with the label
|
||||||
|
lore issues -l dark-mode # Related issues
|
||||||
|
lore timeline "dark mode" --depth 2 # Cross-referenced events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Works reasonably well with labels as the grouping mechanism. But if the team didn't label consistently, there's no way to discover related MRs by content similarity. No "related items" view that combines issues + MRs + discussions for a topic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H14. Checking if a Similar Fix Was Already Attempted
|
||||||
|
|
||||||
|
**Problem:** "Before I implement this fix, was something similar tried before?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore search "memory leak connection pool" # Semantic search
|
||||||
|
lore search "connection pool" --type mr -s all # Wait, no state filter on search
|
||||||
|
lore mrs -s closed -l bugfix # Closed bugfix MRs (coarse)
|
||||||
|
lore timeline "connection pool" # Historical context
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Search doesn't have a `--state` filter. Can't search only closed/merged items. The semantic search is powerful but can't be combined with entity state. Would benefit from `--state merged` on search to find past attempts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H15. Reviewing Discussions That Need My Attention
|
||||||
|
|
||||||
|
**Problem:** "Which discussion threads am I involved in that are still unresolved?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who --active # All active unresolved discussions
|
||||||
|
lore who --active --since 30d # Wider window
|
||||||
|
# But can't filter to "discussions I'm in"...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** `--active` shows all unresolved discussions, not filtered by participant. No way to say "show me discussions where @me participated." No notification/mention tracking. No "my unresolved threads" view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: AI Agent Flows
|
||||||
|
|
||||||
|
### A1. Context Gathering Before Code Modification
|
||||||
|
|
||||||
|
**Problem:** Agent is about to modify `src/features/auth/session.rs` and needs full context.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J health # Pre-flight check
|
||||||
|
lore -J who src/features/auth/ # Who knows this area
|
||||||
|
lore -J search "auth session" -n 10 # Related issues/MRs
|
||||||
|
lore -J mrs -s merged --since 3m -l auth # Recent auth changes
|
||||||
|
lore -J who --overlap src/features/auth/session.rs # Concurrent work risk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to check "are there open MRs touching this file right now?" The overlap mode shows historical touches, not active branches. An agent needs to know about in-flight changes to avoid conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A2. Auto-Triaging an Incoming Issue
|
||||||
|
|
||||||
|
**Problem:** Agent receives a new issue and needs to categorize it, find related work, and suggest assignees.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues 999 # Read the new issue
|
||||||
|
lore -J search "$(extract_keywords)" --explain # Find similar past issues
|
||||||
|
lore -J who src/affected/path/ # Suggest experts as assignees
|
||||||
|
lore -J issues -s opened -l same-label # Check for duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to get just the description text for programmatic keyword extraction. `issues <iid>` returns full detail including discussions. Agent must parse the full response to extract the description for a secondary search. Would benefit from `--fields description` on detail view. No duplicate detection built in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A3. Generating Sprint Status Report
|
||||||
|
|
||||||
|
**Problem:** Agent needs to produce a weekly status report for the team.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -s closed --since 1w --fields minimal # Completed work
|
||||||
|
lore -J issues -s opened --status "In progress" # In-flight work
|
||||||
|
lore -J mrs -s merged --since 1w --fields minimal # Merged PRs
|
||||||
|
lore -J mrs -s opened -D --fields minimal # Open non-draft MRs
|
||||||
|
lore -J count issues # Totals
|
||||||
|
lore -J count mrs # MR totals
|
||||||
|
lore -J who --active --since 1w # Discussions needing attention
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Seven separate queries for one report. No `lore summary` or `lore report` command. No way to get "issues transitioned from X to Y this week" (state change history exists in events but isn't queryable). No velocity metric (issues closed per week trend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A4. Finding Relevant Prior Art Before Implementing
|
||||||
|
|
||||||
|
**Problem:** Agent is implementing a caching layer and wants to find if similar patterns exist in the codebase's GitLab history.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "caching" --mode hybrid -n 20 --explain
|
||||||
|
lore -J search "cache invalidation" --mode hybrid -n 10
|
||||||
|
lore -J search "redis" --mode lexical --type discussion # Exact term in discussions
|
||||||
|
lore -J timeline "cache" --since 1y # Wait, max is 1y? Let's try 12m
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to search discussion notes individually (per-note search). Discussions are aggregated into documents, so individual note-level matches are lost. The `--explain` flag helps but doesn't show which specific note matched. No `--since 1y` or `--since 12m` duration format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A5. Building Context for PR Description
|
||||||
|
|
||||||
|
**Problem:** Agent wrote code and needs to generate a PR description that references relevant issues.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "feature description keywords" --type issue
|
||||||
|
lore -J issues -s opened -l feature-label --fields iid,title,web_url
|
||||||
|
# Cross-reference: which issues does this MR close?
|
||||||
|
# No command for this -- must manually scan search results
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to query the `entity_references` table directly. Agent can't ask "which issues reference MR !456" or "which issues contain 'closes #123' in their text." The data exists but isn't exposed as a query surface. Would benefit from `lore refs --mr 456` or `lore refs --issue 123`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A6. Identifying Affected Experts for Review Assignment
|
||||||
|
|
||||||
|
**Problem:** Agent needs to automatically assign reviewers based on the files changed in an MR.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J mrs 456 # Get MR details
|
||||||
|
# Parse file paths from response... but file changes aren't in the output
|
||||||
|
lore -J who src/path/from/mr/ # Query each path
|
||||||
|
lore -J who src/another/path/ # One at a time...
|
||||||
|
lore -J who @candidate --fields minimal # Check workload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** MR detail view (`mrs <iid>`) doesn't include the file change list from `mr_file_changes`. Agent can't programmatically extract which files an MR touches. Must fall back to GitLab API or guess from description. The `who` command doesn't accept multiple paths. No "auto-reviewer" suggestion combining expertise + availability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A7. Incident Investigation and Timeline Reconstruction
|
||||||
|
|
||||||
|
**Problem:** Agent needs to reconstruct what happened during an outage for a postmortem.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J timeline "outage" --since 3d --depth 2 --expand-mentions
|
||||||
|
lore -J search "error 500" --since 3d
|
||||||
|
lore -J mrs -s merged --since 3d -p production-service
|
||||||
|
lore -J issues --status "In progress" -p production-service
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Timeline is keyword-seeded, which means if the outage wasn't described with that exact term, seeds may miss it. No way to seed a timeline from an entity ID (e.g., "start from issue #321 and expand outward"). No severity/priority filter. No way to correlate with merge times.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A8. Cross-Project Impact Assessment
|
||||||
|
|
||||||
|
**Problem:** Agent needs to understand how a breaking API change in project A affects projects B and C.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "api-endpoint-name" -p project-a
|
||||||
|
lore -J search "api-endpoint-name" -p project-b
|
||||||
|
lore -J search "api-endpoint-name" -p project-c
|
||||||
|
# Or without project filter to search everywhere:
|
||||||
|
lore -J search "api-endpoint-name" -n 50
|
||||||
|
lore -J timeline "api-endpoint-name" --depth 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Cross-project references in entity_references are tracked but the timeline shows unresolved references for entities not synced locally. No way to see a cross-project dependency map. Search works across projects but doesn't group results by project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A9. Automated Stale Issue Recommendations
|
||||||
|
|
||||||
|
**Problem:** Agent runs weekly to identify issues that should be closed or re-prioritized.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -s opened --sort updated --asc -n 100 # Oldest first
|
||||||
|
# For each issue, check:
|
||||||
|
lore -J issues <iid> # Read details
|
||||||
|
lore -J search "<issue title keywords>" # Any recent activity?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No `--updated-before` filter, so agent must fetch all and filter client-side. No way to detect "issue has no assignee AND no activity in 90 days." The 100-issue limit means pagination is needed for large backlogs, but there's no cursor/offset pagination -- only `--limit`. Agent must do N+1 queries to inspect each candidate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A10. Code Review Preparation (File-Level Context)
|
||||||
|
|
||||||
|
**Problem:** Agent is reviewing MR !789 and needs to understand the history of each changed file.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J mrs 789 # Get MR details
|
||||||
|
# Can't get file list from output...
|
||||||
|
# Fall back to search by MR title keywords
|
||||||
|
lore -J search "feature-from-mr" --type mr
|
||||||
|
lore -J who src/guessed/path/ # Expertise for each file
|
||||||
|
lore -J who --overlap src/guessed/path/ # Concurrent changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Same as A6 -- `mr_file_changes` data isn't exposed. Agent is blind to the actual files in the MR unless it parses the description or uses the GitLab API directly. This is the single biggest gap for automated code review workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A11. Building a Knowledge Graph of Entity Relationships
|
||||||
|
|
||||||
|
**Problem:** Agent wants to map how issues, MRs, and discussions are connected for a feature.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "feature-name" -n 30
|
||||||
|
lore -J timeline "feature-name" --depth 2 --max-entities 100
|
||||||
|
# Timeline shows expanded entities and cross-refs, but...
|
||||||
|
# No way to query entity_references directly
|
||||||
|
# No way to get "all entities that reference issue #123"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** The `entity_references` table (closes, related, mentioned) is used internally by timeline but isn't queryable as a standalone command. Agent can't ask "what closes issue #123?" or "what does MR !456 reference?" No graph export. Would enable powerful dependency mapping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A12. Release Readiness Assessment
|
||||||
|
|
||||||
|
**Problem:** Agent needs to verify all issues in milestone "v2.0" are closed and MRs are merged.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -m "v2.0" -s opened # Any open issues in milestone?
|
||||||
|
lore -J issues -m "v2.0" -s closed # Closed issues
|
||||||
|
# MRs don't have milestone filter...
|
||||||
|
lore -J mrs -s opened -l "v2.0" # Try label as proxy
|
||||||
|
lore -J who --active -p myproject # Unresolved discussions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** MRs don't have a `--milestone` filter (issues do). No way to check "all MRs linked to issues in milestone v2.0" -- would require joining `entity_references` with issue milestone. No release checklist concept. No way to verify "every issue in this milestone has a closing MR."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A13. Answering "What Changed?" Between Two Points
|
||||||
|
|
||||||
|
**Problem:** Agent needs to diff project state between two dates for a stakeholder report.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -s closed --since 2w --fields minimal # Recently closed
|
||||||
|
lore -J issues -s opened --since 2w --fields minimal # Recently opened
|
||||||
|
lore -J mrs -s merged --since 2w --fields minimal # Recently merged
|
||||||
|
# But no way to get "issues that CHANGED STATE" in a window
|
||||||
|
# An issue opened 3 months ago but closed yesterday won't appear in --since 2w for issues -s opened
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** `--since` filters by `updated_at`, not by "state changed at." An issue closed yesterday but created 6 months ago would appear in `issues -s closed --since 1d` (because updated_at changed), but the semantics are subtle. No explicit "state transitions in time window" query. The resource_state_events table has this data but it's not exposed as a filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A14. Meeting Prep: Summarize Recent Activity for a Stakeholder
|
||||||
|
|
||||||
|
**Problem:** Agent needs to prepare a 2-minute summary for a project sponsor meeting.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J count issues -p project # Current totals
|
||||||
|
lore -J count mrs -p project # MR totals
|
||||||
|
lore -J issues -s closed --since 1w -p project --fields minimal
|
||||||
|
lore -J mrs -s merged --since 1w -p project --fields minimal
|
||||||
|
lore -J issues -s opened --status "In progress" -p project
|
||||||
|
lore -J who --active -p project --since 1w
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Six queries, same as A3. No summary/dashboard command. Agent must synthesize all responses. No trend data (is the open issue count growing or shrinking?). No "highlights" extraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A15. Determining If Work Is Safe to Start (Conflict Detection)
|
||||||
|
|
||||||
|
**Problem:** Agent is about to start work on an issue and needs to check nobody else is already working on it.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues 123 # Read the issue
|
||||||
|
# Check assignees from response
|
||||||
|
lore -J mrs -s opened -A other-person # Are they working on related MRs?
|
||||||
|
lore -J who --overlap src/target/path/ # Anyone actively touching these files?
|
||||||
|
lore -J search "issue-123-keywords" --type mr -s opened # Wait, search has no --state
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to check "is there an open MR that closes issue #123?" -- the entity_references data exists but isn't queryable. Search doesn't support `--state` filter. No "conflict detection" or "in-flight work" check. Agent must do multiple queries and manually correlate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: Gap Summary
|
||||||
|
|
||||||
|
### Critical Gaps (high impact, blocks common workflows)
|
||||||
|
|
||||||
|
| # | Gap | Affected Flows | Suggested Command/Flag |
|
||||||
|
|---|-----|----------------|----------------------|
|
||||||
|
| 1 | **MR file changes not surfaced** | H4, A6, A10 | `lore mrs <iid> --files` or include in detail view |
|
||||||
|
| 2 | **Entity references not queryable** | H7, A5, A11, A15 | `lore refs --issue 123` / `lore refs --mr 456` |
|
||||||
|
| 3 | **Per-note search missing** | H7, A4 | `lore search --granularity note` (PRD exists) |
|
||||||
|
| 4 | **No entity-based timeline** | H11, A7 | `lore timeline --issue 321` / `lore timeline --mr 456` |
|
||||||
|
| 5 | **No @me / current-user alias** | H1, H15 | Resolve from auth token automatically |
|
||||||
|
|
||||||
|
### Important Gaps (significant friction, multiple workarounds needed)
|
||||||
|
|
||||||
|
| # | Gap | Affected Flows | Suggested Command/Flag |
|
||||||
|
|---|-----|----------------|----------------------|
|
||||||
|
| 6 | **No activity feed / summary** | H1, A3, A14 | `lore activity --since 1d` or `lore summary` |
|
||||||
|
| 7 | **No multi-path who query** | H6, A6 | `lore who src/path1/ src/path2/` |
|
||||||
|
| 8 | **No --state filter on search** | H14, A15 | `lore search --state merged` |
|
||||||
|
| 9 | **MRs missing --milestone filter** | H9, A12 | `lore mrs -m "v2.0"` |
|
||||||
|
| 10 | **No --no-assignee / --unassigned** | H2 | `lore issues --no-assignee` |
|
||||||
|
| 11 | **No --updated-before filter** | H10, A9 | `lore issues --before 90d` or `--stale 90d` |
|
||||||
|
| 12 | **No team workload view** | H8 | `lore who --team` or `lore workload` |
|
||||||
|
|
||||||
|
### Nice-to-Have Gaps (would improve agent efficiency)
|
||||||
|
|
||||||
|
| # | Gap | Affected Flows | Suggested Command/Flag |
|
||||||
|
|---|-----|----------------|----------------------|
|
||||||
|
| 13 | **No pagination/offset** | A9 | `--offset 100` for large result sets |
|
||||||
|
| 14 | **No detail --fields on show** | A2 | `lore issues 999 --fields description` |
|
||||||
|
| 15 | **No cross-project grouping** | A8 | `lore search --group-by project` |
|
||||||
|
| 16 | **No trend/velocity metrics** | A3, A14 | `lore trends issues --period week` |
|
||||||
|
| 17 | **No --for-issue on mrs** | A12, A15 | `lore mrs --closes 123` (query entity_refs) |
|
||||||
|
| 18 | **1y/12m duration not supported** | A4 | Support `1y`, `12m`, `365d` in --since |
|
||||||
|
| 19 | **No discussion participant filter** | H15 | `lore who --active --participant @me` |
|
||||||
|
| 20 | **No sort by due date** | H2 | `lore issues --sort due` |
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
Below are the highest-leverage revisions I’d make to this plan. I’m focusing on correctness pitfalls, SQLite gotchas, query performance on 280K notes, and reducing “dynamic SQL + param juggling” complexity—without turning this into a new ingestion project.
|
|
||||||
|
|
||||||
Change 1 — Fix a hard SQLite bug in --active (GROUP_CONCAT DISTINCT + separator)
|
|
||||||
Why
|
|
||||||
|
|
||||||
SQLite does not allow GROUP_CONCAT(DISTINCT x, sep). With DISTINCT, SQLite only permits a single argument (GROUP_CONCAT(DISTINCT x)). Your current query will error at runtime in many SQLite versions.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Use a subquery that selects distinct participants, then GROUP_CONCAT with your separator.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ fn query_active(...)
|
|
||||||
- (SELECT GROUP_CONCAT(DISTINCT n.author_username, X'1F')
|
|
||||||
- FROM notes n
|
|
||||||
- WHERE n.discussion_id = d.id
|
|
||||||
- AND n.is_system = 0
|
|
||||||
- AND n.author_username IS NOT NULL) AS participants
|
|
||||||
+ (SELECT GROUP_CONCAT(username, X'1F') FROM (
|
|
||||||
+ SELECT DISTINCT n.author_username AS username
|
|
||||||
+ FROM notes n
|
|
||||||
+ WHERE n.discussion_id = d.id
|
|
||||||
+ AND n.is_system = 0
|
|
||||||
+ AND n.author_username IS NOT NULL
|
|
||||||
+ ORDER BY username
|
|
||||||
+ )) AS participants
|
|
||||||
|
|
||||||
Change 2 — Replace “contains('.') => exact file match” with segment-aware path classification
|
|
||||||
Why
|
|
||||||
|
|
||||||
path.contains('.') misclassifies directories like:
|
|
||||||
|
|
||||||
.github/workflows/
|
|
||||||
|
|
||||||
src/v1.2/auth/
|
|
||||||
|
|
||||||
It also fails the “root file” case (README.md) because your mode discriminator only treats paths as paths if they contain /.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Add explicit --path to force Expert mode (covers root files cleanly).
|
|
||||||
|
|
||||||
Classify file-vs-dir by checking last path segment for a dot, and whether the input ends with /.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ pub struct WhoArgs {
|
|
||||||
- /// Username or file path (path if contains /)
|
|
||||||
- pub target: Option<String>,
|
|
||||||
+ /// Username or file path shorthand (ambiguous for root files like README.md)
|
|
||||||
+ pub target: Option<String>,
|
|
||||||
+
|
|
||||||
+ /// Force expert mode for a file/directory path (supports root files like README.md)
|
|
||||||
+ #[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])]
|
|
||||||
+ pub path: Option<String>,
|
|
||||||
@@ fn resolve_mode<'a>(args: &'a WhoArgs) -> Result<WhoMode<'a>> {
|
|
||||||
- if let Some(target) = &args.target {
|
|
||||||
+ if let Some(p) = &args.path {
|
|
||||||
+ return Ok(WhoMode::Expert { path: p });
|
|
||||||
+ }
|
|
||||||
+ if let Some(target) = &args.target {
|
|
||||||
let clean = target.strip_prefix('@').unwrap_or(target);
|
|
||||||
if args.reviews {
|
|
||||||
return Ok(WhoMode::Reviews { username: clean });
|
|
||||||
}
|
|
||||||
- // Disambiguation: if target contains '/', it's a file path.
|
|
||||||
- // GitLab usernames never contain '/'.
|
|
||||||
- if target.contains('/') {
|
|
||||||
+ // Disambiguation:
|
|
||||||
+ // - treat as path if it contains '/'
|
|
||||||
+ // - otherwise treat as username (root files require --path)
|
|
||||||
+ if target.contains('/') {
|
|
||||||
return Ok(WhoMode::Expert { path: target });
|
|
||||||
}
|
|
||||||
return Ok(WhoMode::Workload { username: clean });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
And update the path pattern logic used by Expert/Overlap:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ fn query_expert(...)
|
|
||||||
- // Normalize path for LIKE matching: add trailing % if no extension
|
|
||||||
- let path_pattern = if path.contains('.') {
|
|
||||||
- path.to_string() // Exact file match
|
|
||||||
- } else {
|
|
||||||
- let trimmed = path.trim_end_matches('/');
|
|
||||||
- format!("{trimmed}/%")
|
|
||||||
- };
|
|
||||||
+ // Normalize:
|
|
||||||
+ // - if ends_with('/') => directory prefix
|
|
||||||
+ // - else if last segment contains '.' => file exact match
|
|
||||||
+ // - else => directory prefix
|
|
||||||
+ let trimmed = path.trim_end_matches('/');
|
|
||||||
+ let last = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
||||||
+ let is_file = !path.ends_with('/') && last.contains('.');
|
|
||||||
+ let path_pattern = if is_file { trimmed.to_string() } else { format!("{trimmed}/%") };
|
|
||||||
|
|
||||||
Change 3 — Stop building dynamic SQL strings for optional filters; always bind params
|
|
||||||
Why
|
|
||||||
|
|
||||||
Right now you’re mixing:
|
|
||||||
|
|
||||||
dynamic project_clause string fragments
|
|
||||||
|
|
||||||
ad-hoc param vectors
|
|
||||||
|
|
||||||
placeholder renumbering by branch
|
|
||||||
|
|
||||||
That’s brittle and easy to regress (especially when you add more conditions later). SQLite/rusqlite can bind Option<T> to NULL, which enables a simple pattern:
|
|
||||||
|
|
||||||
sql
|
|
||||||
Copy code
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
|
|
||||||
Revision (representative; apply to all queries)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ fn query_expert(...)
|
|
||||||
- let project_clause = if project_id.is_some() {
|
|
||||||
- "AND n.project_id = ?3"
|
|
||||||
- } else {
|
|
||||||
- ""
|
|
||||||
- };
|
|
||||||
-
|
|
||||||
- let sql = format!(
|
|
||||||
+ let sql = format!(
|
|
||||||
"SELECT username, role, activity_count, last_active_at FROM (
|
|
||||||
@@
|
|
||||||
FROM notes n
|
|
||||||
WHERE n.position_new_path LIKE ?1
|
|
||||||
AND n.is_system = 0
|
|
||||||
AND n.author_username IS NOT NULL
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
- {project_clause}
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
@@
|
|
||||||
WHERE n.position_new_path LIKE ?1
|
|
||||||
AND m.author_username IS NOT NULL
|
|
||||||
AND m.updated_at >= ?2
|
|
||||||
- {project_clause}
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY m.author_username
|
|
||||||
- )"
|
|
||||||
+ ) t"
|
|
||||||
);
|
|
||||||
-
|
|
||||||
- let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
- params.push(Box::new(path_pattern.clone()));
|
|
||||||
- params.push(Box::new(since_ms));
|
|
||||||
- if let Some(pid) = project_id {
|
|
||||||
- params.push(Box::new(pid));
|
|
||||||
- }
|
|
||||||
- let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
+ let param_refs = rusqlite::params![path_pattern, since_ms, project_id];
|
|
||||||
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
Adds required derived-table alias t (some SQLite configurations are stricter).
|
|
||||||
|
|
||||||
Eliminates the dynamic param vector and placeholder gymnastics.
|
|
||||||
|
|
||||||
Change 4 — Filter “path touch” queries to DiffNotes and escape LIKE properly
|
|
||||||
Why
|
|
||||||
|
|
||||||
Only DiffNotes reliably have position_new_path; including other note types can skew counts and harm performance.
|
|
||||||
|
|
||||||
LIKE treats % and _ as wildcards—rare in file paths, but not impossible (generated files, templates). Escaping is a low-cost robustness win.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Add note_type='DiffNote' and LIKE ... ESCAPE '\' plus a tiny escape helper.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ fn query_expert(...)
|
|
||||||
- FROM notes n
|
|
||||||
- WHERE n.position_new_path LIKE ?1
|
|
||||||
+ FROM notes n
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
+ AND n.position_new_path LIKE ?1 ESCAPE '\'
|
|
||||||
AND n.is_system = 0
|
|
||||||
@@
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ Helper Functions
|
|
||||||
+fn escape_like(input: &str) -> String {
|
|
||||||
+ input.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
|
|
||||||
+}
|
|
||||||
|
|
||||||
|
|
||||||
And when building patterns:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
- let path_pattern = if is_file { trimmed.to_string() } else { format!("{trimmed}/%") };
|
|
||||||
+ let base = escape_like(trimmed);
|
|
||||||
+ let path_pattern = if is_file { base } else { format!("{base}/%") };
|
|
||||||
|
|
||||||
|
|
||||||
Apply the same changes to query_overlap and any other position_new_path LIKE ....
|
|
||||||
|
|
||||||
Change 5 — Use note timestamps for “touch since” semantics (Expert/Overlap author branch)
|
|
||||||
Why
|
|
||||||
|
|
||||||
In Expert/Overlap “author” branches you filter by m.updated_at >= since. That answers “MR updated recently” rather than “MR touched at this path recently”, which can surface stale ownership.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Filter by the note creation time (and use it for “last touch” where relevant). You can still compute author activity, but anchor it to note activity.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ fn query_overlap(...)
|
|
||||||
- WHERE n.position_new_path LIKE ?1
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
+ AND n.position_new_path LIKE ?1 ESCAPE '\'
|
|
||||||
AND m.state IN ('opened', 'merged')
|
|
||||||
AND m.author_username IS NOT NULL
|
|
||||||
- AND m.updated_at >= ?2
|
|
||||||
+ AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR m.project_id = ?3)
|
|
||||||
|
|
||||||
|
|
||||||
Same idea in Expert mode’s “MR authors” branch.
|
|
||||||
|
|
||||||
Change 6 — Workload mode: apply --since consistently to unresolved discussions
|
|
||||||
Why
|
|
||||||
|
|
||||||
Workload’s unresolved discussions ignore since_ms. That makes --since partially misleading and can dump very old threads.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Filter on d.last_note_at when since_ms is set.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ fn query_workload(...)
|
|
||||||
- let disc_sql = format!(
|
|
||||||
+ let disc_since = if since_ms.is_some() {
|
|
||||||
+ "AND d.last_note_at >= ?2"
|
|
||||||
+ } else { "" };
|
|
||||||
+ let disc_sql = format!(
|
|
||||||
"SELECT d.noteable_type,
|
|
||||||
@@
|
|
||||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
|
||||||
AND EXISTS (
|
|
||||||
@@
|
|
||||||
)
|
|
||||||
{disc_project_filter}
|
|
||||||
+ {disc_since}
|
|
||||||
ORDER BY d.last_note_at DESC
|
|
||||||
LIMIT {limit}"
|
|
||||||
);
|
|
||||||
@@
|
|
||||||
- // Rebuild params for discussion query (only username + optional project_id)
|
|
||||||
- let mut disc_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
||||||
- disc_params.push(Box::new(username.to_string()));
|
|
||||||
- if let Some(pid) = project_id {
|
|
||||||
- disc_params.push(Box::new(pid));
|
|
||||||
- }
|
|
||||||
+ // Params: username, since_ms, project_id (NULLs ok)
|
|
||||||
+ let disc_param_refs = rusqlite::params![username, since_ms, project_id];
|
|
||||||
|
|
||||||
|
|
||||||
(If you adopt Change 3 fully, this becomes very clean.)
|
|
||||||
|
|
||||||
Change 7 — Make Overlap results represent “both roles” instead of collapsing to one
|
|
||||||
Why
|
|
||||||
|
|
||||||
Collapsing to a single role loses valuable info (“they authored and reviewed”). Also your current “prefer author” rule is arbitrary for the “who else is touching this” question.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Track role counts separately and render as A, R, or A+R.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ pub struct OverlapUser {
|
|
||||||
pub username: String,
|
|
||||||
- pub role: String,
|
|
||||||
- pub touch_count: u32,
|
|
||||||
+ pub author_touch_count: u32,
|
|
||||||
+ pub review_touch_count: u32,
|
|
||||||
+ pub touch_count: u32,
|
|
||||||
pub last_touch_at: i64,
|
|
||||||
pub mr_iids: Vec<i64>,
|
|
||||||
}
|
|
||||||
@@ fn query_overlap(...)
|
|
||||||
- let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapUser {
|
|
||||||
+ let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapUser {
|
|
||||||
username: username.clone(),
|
|
||||||
- role: role.clone(),
|
|
||||||
+ author_touch_count: 0,
|
|
||||||
+ review_touch_count: 0,
|
|
||||||
touch_count: 0,
|
|
||||||
last_touch_at: 0,
|
|
||||||
mr_iids: Vec::new(),
|
|
||||||
});
|
|
||||||
entry.touch_count += count;
|
|
||||||
+ if role == "author" { entry.author_touch_count += count; }
|
|
||||||
+ if role == "reviewer" { entry.review_touch_count += count; }
|
|
||||||
@@ human output
|
|
||||||
- println!(
|
|
||||||
- " {:<16} {:<8} {:>7} {:<12} {}",
|
|
||||||
+ println!(
|
|
||||||
+ " {:<16} {:<6} {:>7} {:<12} {}",
|
|
||||||
...
|
|
||||||
);
|
|
||||||
@@
|
|
||||||
- user.role,
|
|
||||||
+ format_roles(user.author_touch_count, user.review_touch_count),
|
|
||||||
|
|
||||||
Change 8 — Add an “Index Audit + optional migration” step (big perf win, low blast radius)
|
|
||||||
Why
|
|
||||||
|
|
||||||
With 280K notes, the path/timestamp queries will degrade quickly without indexes. This isn’t “scope creep”; it’s making the feature usable.
|
|
||||||
|
|
||||||
Revision (plan-level)
|
|
||||||
|
|
||||||
Add a non-breaking migration that only creates indexes if missing.
|
|
||||||
|
|
||||||
Optionally add a runtime check: if EXPLAIN QUERY PLAN indicates full table scan on notes, print a dim warning in human mode.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ Implementation Order
|
|
||||||
-| Step | What | Files |
|
|
||||||
+| Step | What | Files |
|
|
||||||
| 1 | CLI skeleton: `WhoArgs` + `Commands::Who` + dispatch + stub | `cli/mod.rs`, `commands/mod.rs`, `main.rs` |
|
|
||||||
+| 1.5 | Index audit + add `CREATE INDEX IF NOT EXISTS` migration for who hot paths | `migrations/0xx_who_indexes.sql` |
|
|
||||||
@@
|
|
||||||
|
|
||||||
|
|
||||||
Suggested indexes (tune names to your conventions):
|
|
||||||
|
|
||||||
notes(note_type, position_new_path, created_at)
|
|
||||||
|
|
||||||
notes(discussion_id, is_system, author_username)
|
|
||||||
|
|
||||||
discussions(resolvable, resolved, last_note_at, project_id)
|
|
||||||
|
|
||||||
merge_requests(project_id, state, updated_at, author_username)
|
|
||||||
|
|
||||||
issue_assignees(username, issue_id)
|
|
||||||
|
|
||||||
Even if SQLite can’t perfectly index LIKE, these still help with join and timestamp filters.
|
|
||||||
|
|
||||||
Change 9 — Make robot JSON reproducible by echoing the effective query inputs
|
|
||||||
Why
|
|
||||||
|
|
||||||
Agent workflows benefit from a stable “query record”: what mode ran, what path/user, resolved project, effective since, limit.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Include an input object in JSON output.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ struct WhoJsonData {
|
|
||||||
mode: String,
|
|
||||||
+ input: serde_json::Value,
|
|
||||||
#[serde(flatten)]
|
|
||||||
result: serde_json::Value,
|
|
||||||
}
|
|
||||||
@@ pub fn print_who_json(...)
|
|
||||||
- let output = WhoJsonEnvelope {
|
|
||||||
+ let input = serde_json::json!({
|
|
||||||
+ "project": /* resolved or raw args.project */,
|
|
||||||
+ "since": /* resolved since ISO */,
|
|
||||||
+ "limit": /* args.limit */,
|
|
||||||
+ });
|
|
||||||
+ let output = WhoJsonEnvelope {
|
|
||||||
ok: true,
|
|
||||||
data: WhoJsonData {
|
|
||||||
mode: mode.to_string(),
|
|
||||||
+ input,
|
|
||||||
result: data,
|
|
||||||
},
|
|
||||||
meta: RobotMeta { elapsed_ms },
|
|
||||||
};
|
|
||||||
|
|
||||||
Change 10 — Tighten clap constraints so invalid combinations never reach resolve_mode
|
|
||||||
Why
|
|
||||||
|
|
||||||
Right now conflicts are enforced manually (or not at all). Clamp the invalid combos at the CLI layer:
|
|
||||||
|
|
||||||
--active should conflict with target, --overlap, --reviews, --path
|
|
||||||
|
|
||||||
--reviews should require a username (and should conflict with Expert path modes)
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@ pub struct WhoArgs {
|
|
||||||
- pub active: bool,
|
|
||||||
+ #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])]
|
|
||||||
+ pub active: bool,
|
|
||||||
@@
|
|
||||||
- pub overlap: Option<String>,
|
|
||||||
+ #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])]
|
|
||||||
+ pub overlap: Option<String>,
|
|
||||||
@@
|
|
||||||
- pub reviews: bool,
|
|
||||||
+ #[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])]
|
|
||||||
+ pub reviews: bool,
|
|
||||||
|
|
||||||
Summary of what I’d definitely change
|
|
||||||
|
|
||||||
If you do nothing else, do these first:
|
|
||||||
|
|
||||||
Fix GROUP_CONCAT(DISTINCT ..., sep) in Active mode (runtime error).
|
|
||||||
|
|
||||||
Path classification: add --path, and stop using contains('.') globally.
|
|
||||||
|
|
||||||
Remove dynamic SQL + param vectors: always bind project_id as nullable and use (? IS NULL OR ...).
|
|
||||||
|
|
||||||
Filter to DiffNotes + LIKE escaping for correctness and fewer rows scanned.
|
|
||||||
|
|
||||||
Optional index migration: otherwise this will feel slow/non-deterministically slow depending on local DB state.
|
|
||||||
|
|
||||||
If you want, I can also provide a consolidated “v2 plan” as a single unified patch (one diff) rather than per-change snippets.
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
Below are the highest-leverage revisions I’d make to iteration 1 to tighten correctness, performance, and “agent usefulness” without blowing up scope. For each change: (1) rationale, (2) a focused unified diff against the plan you pasted.
|
|
||||||
|
|
||||||
Change 1 — Make robot “input echo” actually resolved (project_id, project_path, since_ms/iso, mode)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your Design Principle #5 says the robot envelope should echo resolved inputs (“effective since, resolved project”), but the current input object echoes only raw CLI strings. Agents can’t reliably reproduce or compare runs (e.g., fuzzy project resolution may map differently over time).
|
|
||||||
|
|
||||||
This is also a reliability improvement: “what ran” should be computed once and propagated, not recomputed in output.
|
|
||||||
|
|
||||||
Plan diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@
|
|
||||||
-5. **Robot-first reproducibility.** Robot JSON output includes an `input` object echoing the resolved query parameters (effective since, resolved project, limit) so agents can trace exactly what ran.
|
|
||||||
+5. **Robot-first reproducibility.** Robot JSON output includes a `resolved_input` object (mode, since_ms + since_iso, resolved project_id + project_path, limit, db_path) so agents can trace exactly what ran.
|
|
||||||
|
|
||||||
@@
|
|
||||||
-/// Main entry point. Resolves mode from args and dispatches.
|
|
||||||
-pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoResult> {
|
|
||||||
+/// Main entry point. Resolves mode + resolved inputs once, then dispatches.
|
|
||||||
+pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
|
|
||||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
||||||
let conn = create_connection(&db_path)?;
|
|
||||||
|
|
||||||
- let project_id = args
|
|
||||||
+ let project_id = args
|
|
||||||
.project
|
|
||||||
.as_deref()
|
|
||||||
.map(|p| resolve_project(&conn, p))
|
|
||||||
.transpose()?;
|
|
||||||
+ let project_path = project_id
|
|
||||||
+ .map(|id| lookup_project_path(&conn, id))
|
|
||||||
+ .transpose()?;
|
|
||||||
|
|
||||||
let mode = resolve_mode(args)?;
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
WhoMode::Expert { path } => {
|
|
||||||
let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
|
||||||
let result = query_expert(&conn, path, project_id, since_ms, args.limit)?;
|
|
||||||
- Ok(WhoResult::Expert(result))
|
|
||||||
+ Ok(WhoRun::new("expert", &db_path, project_id, project_path, since_ms, args.limit, WhoResult::Expert(result)))
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+/// Wrapper that carries resolved inputs for reproducible output.
|
|
||||||
+pub struct WhoRun {
|
|
||||||
+ pub mode: String,
|
|
||||||
+ pub resolved_input: WhoResolvedInput,
|
|
||||||
+ pub result: WhoResult,
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+pub struct WhoResolvedInput {
|
|
||||||
+ pub db_path: String,
|
|
||||||
+ pub project_id: Option<i64>,
|
|
||||||
+ pub project_path: Option<String>,
|
|
||||||
+ pub since_ms: i64,
|
|
||||||
+ pub since_iso: String,
|
|
||||||
+ pub limit: usize,
|
|
||||||
+}
|
|
||||||
@@
|
|
||||||
-pub fn print_who_json(result: &WhoResult, args: &WhoArgs, elapsed_ms: u64) {
|
|
||||||
- let (mode, data) = match result {
|
|
||||||
+pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
|
|
||||||
+ let (mode, data) = match &run.result {
|
|
||||||
WhoResult::Expert(r) => ("expert", expert_to_json(r)),
|
|
||||||
@@
|
|
||||||
- let input = serde_json::json!({
|
|
||||||
+ let input = serde_json::json!({
|
|
||||||
"target": args.target,
|
|
||||||
"path": args.path,
|
|
||||||
"project": args.project,
|
|
||||||
"since": args.since,
|
|
||||||
"limit": args.limit,
|
|
||||||
});
|
|
||||||
+
|
|
||||||
+ let resolved_input = serde_json::json!({
|
|
||||||
+ "mode": run.mode,
|
|
||||||
+ "db_path": run.resolved_input.db_path,
|
|
||||||
+ "project_id": run.resolved_input.project_id,
|
|
||||||
+ "project_path": run.resolved_input.project_path,
|
|
||||||
+ "since_ms": run.resolved_input.since_ms,
|
|
||||||
+ "since_iso": run.resolved_input.since_iso,
|
|
||||||
+ "limit": run.resolved_input.limit,
|
|
||||||
+ });
|
|
||||||
@@
|
|
||||||
- data: WhoJsonData {
|
|
||||||
- mode: mode.to_string(),
|
|
||||||
- input,
|
|
||||||
- result: data,
|
|
||||||
- },
|
|
||||||
+ data: WhoJsonData { mode: mode.to_string(), input, resolved_input, result: data },
|
|
||||||
meta: RobotMeta { elapsed_ms },
|
|
||||||
};
|
|
||||||
@@
|
|
||||||
struct WhoJsonData {
|
|
||||||
mode: String,
|
|
||||||
input: serde_json::Value,
|
|
||||||
+ resolved_input: serde_json::Value,
|
|
||||||
#[serde(flatten)]
|
|
||||||
result: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
Change 2 — Remove dynamic SQL format!(..LIMIT {limit}) and parameterize LIMIT everywhere
|
|
||||||
Why
|
|
||||||
|
|
||||||
You explicitly prefer static SQL ((?N IS NULL OR ...)) to avoid subtle bugs; but Workload/Active use format! for LIMIT. Even though limit is typed, it’s an inconsistency that complicates statement caching and encourages future string assembly creep.
|
|
||||||
|
|
||||||
SQLite supports LIMIT ? with bound parameters; rusqlite can bind an i64.
|
|
||||||
|
|
||||||
Plan diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@
|
|
||||||
- let issues_sql = format!(
|
|
||||||
- "SELECT ...
|
|
||||||
- ORDER BY i.updated_at DESC
|
|
||||||
- LIMIT {limit}"
|
|
||||||
- );
|
|
||||||
- let mut stmt = conn.prepare(&issues_sql)?;
|
|
||||||
+ let issues_sql =
|
|
||||||
+ "SELECT ...
|
|
||||||
+ ORDER BY i.updated_at DESC
|
|
||||||
+ LIMIT ?4";
|
|
||||||
+ let mut stmt = conn.prepare(issues_sql)?;
|
|
||||||
let assigned_issues: Vec<WorkloadIssue> = stmt
|
|
||||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
|
||||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
|
||||||
@@
|
|
||||||
- let authored_sql = format!(
|
|
||||||
- "SELECT ...
|
|
||||||
- ORDER BY m.updated_at DESC
|
|
||||||
- LIMIT {limit}"
|
|
||||||
- );
|
|
||||||
- let mut stmt = conn.prepare(&authored_sql)?;
|
|
||||||
+ let authored_sql =
|
|
||||||
+ "SELECT ...
|
|
||||||
+ ORDER BY m.updated_at DESC
|
|
||||||
+ LIMIT ?4";
|
|
||||||
+ let mut stmt = conn.prepare(authored_sql)?;
|
|
||||||
@@
|
|
||||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
|
||||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
|
||||||
@@
|
|
||||||
- let reviewing_sql = format!(
|
|
||||||
- "SELECT ...
|
|
||||||
- ORDER BY m.updated_at DESC
|
|
||||||
- LIMIT {limit}"
|
|
||||||
- );
|
|
||||||
- let mut stmt = conn.prepare(&reviewing_sql)?;
|
|
||||||
+ let reviewing_sql =
|
|
||||||
+ "SELECT ...
|
|
||||||
+ ORDER BY m.updated_at DESC
|
|
||||||
+ LIMIT ?4";
|
|
||||||
+ let mut stmt = conn.prepare(reviewing_sql)?;
|
|
||||||
@@
|
|
||||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
|
||||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
|
||||||
@@
|
|
||||||
- let disc_sql = format!(
|
|
||||||
- "SELECT ...
|
|
||||||
- ORDER BY d.last_note_at DESC
|
|
||||||
- LIMIT {limit}"
|
|
||||||
- );
|
|
||||||
- let mut stmt = conn.prepare(&disc_sql)?;
|
|
||||||
+ let disc_sql =
|
|
||||||
+ "SELECT ...
|
|
||||||
+ ORDER BY d.last_note_at DESC
|
|
||||||
+ LIMIT ?4";
|
|
||||||
+ let mut stmt = conn.prepare(disc_sql)?;
|
|
||||||
@@
|
|
||||||
- .query_map(rusqlite::params![username, project_id, since_ms], |row| {
|
|
||||||
+ .query_map(rusqlite::params![username, project_id, since_ms, limit as i64], |row| {
|
|
||||||
@@
|
|
||||||
- let sql = format!(
|
|
||||||
- "SELECT ...
|
|
||||||
- ORDER BY d.last_note_at DESC
|
|
||||||
- LIMIT {limit}"
|
|
||||||
- );
|
|
||||||
- let mut stmt = conn.prepare(&sql)?;
|
|
||||||
+ let sql =
|
|
||||||
+ "SELECT ...
|
|
||||||
+ ORDER BY d.last_note_at DESC
|
|
||||||
+ LIMIT ?3";
|
|
||||||
+ let mut stmt = conn.prepare(sql)?;
|
|
||||||
@@
|
|
||||||
- .query_map(rusqlite::params![since_ms, project_id], |row| {
|
|
||||||
+ .query_map(rusqlite::params![since_ms, project_id, limit as i64], |row| {
|
|
||||||
|
|
||||||
Change 3 — Fix path matching for dotless files (LICENSE/Makefile) via “exact OR prefix” (no new flags)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your improved “dot only in last segment” heuristic still fails on dotless files (LICENSE, Makefile, Dockerfile) which are common, especially at repo root. Right now they’ll be treated as directories (LICENSE/%) and silently return nothing.
|
|
||||||
|
|
||||||
Best minimal UX: if user provides a path that’s ambiguous (no trailing slash), match either exact file OR directory prefix.
|
|
||||||
|
|
||||||
Plan diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@
|
|
||||||
-/// Build a LIKE pattern from a user-supplied path, with proper LIKE escaping.
|
|
||||||
-///
|
|
||||||
-/// Rules:
|
|
||||||
-/// - If the path ends with `/`, it's a directory prefix → `escaped_path%`
|
|
||||||
-/// - If the last path segment contains `.`, it's a file → exact match
|
|
||||||
-/// - Otherwise, it's a directory prefix → `escaped_path/%`
|
|
||||||
+/// Build an exact + prefix match from a user-supplied path, with proper LIKE escaping.
|
|
||||||
+///
|
|
||||||
+/// Rules:
|
|
||||||
+/// - If the path ends with `/`, treat as directory-only (prefix match)
|
|
||||||
+/// - Otherwise, treat as ambiguous: exact match OR directory prefix
|
|
||||||
+/// (fixes dotless files like LICENSE/Makefile without requiring new flags)
|
|
||||||
@@
|
|
||||||
-fn build_path_pattern(path: &str) -> String {
|
|
||||||
+struct PathMatch {
|
|
||||||
+ exact: String,
|
|
||||||
+ prefix: String,
|
|
||||||
+ dir_only: bool,
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+fn build_path_match(path: &str) -> PathMatch {
|
|
||||||
let trimmed = path.trim_end_matches('/');
|
|
||||||
- let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
||||||
- let is_file = !path.ends_with('/') && last_segment.contains('.');
|
|
||||||
let escaped = escape_like(trimmed);
|
|
||||||
-
|
|
||||||
- if is_file {
|
|
||||||
- escaped
|
|
||||||
- } else {
|
|
||||||
- format!("{escaped}/%")
|
|
||||||
- }
|
|
||||||
+ PathMatch {
|
|
||||||
+ exact: escaped.clone(),
|
|
||||||
+ prefix: format!("{escaped}/%"),
|
|
||||||
+ dir_only: path.ends_with('/'),
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
- let path_pattern = build_path_pattern(path);
|
|
||||||
+ let pm = build_path_match(path);
|
|
||||||
@@
|
|
||||||
- AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
+ AND (
|
|
||||||
+ (?4 = 1 AND n.position_new_path LIKE ?2 ESCAPE '\\')
|
|
||||||
+ OR (?4 = 0 AND (n.position_new_path = ?1 OR n.position_new_path LIKE ?2 ESCAPE '\\'))
|
|
||||||
+ )
|
|
||||||
@@
|
|
||||||
- let rows: Vec<(String, String, u32, i64)> = stmt
|
|
||||||
- .query_map(rusqlite::params![path_pattern, since_ms, project_id], |row| {
|
|
||||||
+ let rows: Vec<(String, String, u32, i64)> = stmt
|
|
||||||
+ .query_map(rusqlite::params![pm.exact, pm.prefix, since_ms, i32::from(pm.dir_only), project_id], |row| {
|
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
|
||||||
})?
|
|
||||||
|
|
||||||
|
|
||||||
(Apply the same pattern to Overlap mode.)
|
|
||||||
|
|
||||||
Change 4 — Consistently exclude system notes in all DiffNote-based branches (Expert/Overlap author branches currently don’t)
|
|
||||||
Why
|
|
||||||
|
|
||||||
You filter n.is_system = 0 for reviewer branches, but not in the author branches of Expert/Overlap. That can skew “author touch” via system-generated diff notes or bot activity.
|
|
||||||
|
|
||||||
Consistency here improves correctness and also enables more aggressive partial indexing.
|
|
||||||
|
|
||||||
Plan diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@
|
|
||||||
- WHERE n.note_type = 'DiffNote'
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
+ AND n.is_system = 0
|
|
||||||
AND m.author_username IS NOT NULL
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR m.project_id = ?3)
|
|
||||||
@@
|
|
||||||
- WHERE n.note_type = 'DiffNote'
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
+ AND n.is_system = 0
|
|
||||||
AND m.state IN ('opened', 'merged')
|
|
||||||
AND m.author_username IS NOT NULL
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR m.project_id = ?3)
|
|
||||||
|
|
||||||
Change 5 — Rework Migration 017 indexes to match real predicates + add one critical notes index for discussion participation
|
|
||||||
Why
|
|
||||||
|
|
||||||
(a) idx_notes_diffnote_path_created currently leads with note_type even though it’s constant via partial index. You want the leading columns to match your most selective predicates: position_new_path prefix + created_at range, with optional project_id.
|
|
||||||
|
|
||||||
(b) Active + Workload discussion participation repeatedly hits notes by (discussion_id, author_username); you only guarantee notes(discussion_id) is indexed. Adding a narrow partial composite index pays off immediately for both “participants” and “EXISTS user participated” checks.
|
|
||||||
|
|
||||||
(c) The discussions index should focus on (project_id, last_note_at) with a partial predicate; resolvable/resolved a_
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
Below are the revisions I’d make to iteration 2 to improve correctness, determinism, query-plan quality, and multi-project usability without turning this into a bigger product.
|
|
||||||
|
|
||||||
I’m treating your plan as the “source of truth” and showing git-diff style patches against the plan text/code blocks you included.
|
|
||||||
|
|
||||||
Change 1 — Fix project scoping to hit the right index (DiffNote branches)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your hot-path index is:
|
|
||||||
|
|
||||||
idx_notes_diffnote_path_created ON notes(position_new_path, created_at, project_id) WHERE note_type='DiffNote' AND is_system=0
|
|
||||||
|
|
||||||
But in Expert/Overlap you sometimes scope by m.project_id = ?3 (MR table), not n.project_id = ?3 (notes table). That weakens the optimizer’s ability to use the composite notes index (and can force broader joins before filtering).
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ Query: Expert Mode @@
|
|
||||||
- AND (?3 IS NULL OR m.project_id = ?3)
|
|
||||||
+ -- IMPORTANT: scope on notes.project_id to maximize use of
|
|
||||||
+ -- idx_notes_diffnote_path_created (notes is the selective table)
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
|
|
||||||
@@ Query: Overlap Mode @@
|
|
||||||
- AND (?3 IS NULL OR m.project_id = ?3)
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
|
|
||||||
@@ Query: Overlap Mode (author branch) @@
|
|
||||||
- AND (?3 IS NULL OR m.project_id = ?3)
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
|
|
||||||
Change 2 — Introduce a “prefix vs exact” path query to avoid LIKE when you don’t need it
|
|
||||||
Why
|
|
||||||
|
|
||||||
For exact file paths (e.g. src/auth/login.rs), you currently do:
|
|
||||||
|
|
||||||
position_new_path LIKE ?1 ESCAPE '\' where ?1 has no wildcard
|
|
||||||
|
|
||||||
That’s logically fine, but it’s a worse signal to the planner than = and can degrade performance depending on collation/case settings.
|
|
||||||
|
|
||||||
This doesn’t violate “static SQL” — you can pick between two static query strings.
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ Helper: Path Pattern Construction @@
|
|
||||||
-fn build_path_pattern(path: &str) -> String {
|
|
||||||
+struct PathQuery {
|
|
||||||
+ /// The parameter value to bind.
|
|
||||||
+ value: String,
|
|
||||||
+ /// If true: use LIKE value || '%'. If false: use '='.
|
|
||||||
+ is_prefix: bool,
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+fn build_path_query(path: &str) -> PathQuery {
|
|
||||||
let trimmed = path.trim_end_matches('/');
|
|
||||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
||||||
let is_file = !path.ends_with('/') && last_segment.contains('.');
|
|
||||||
let escaped = escape_like(trimmed);
|
|
||||||
|
|
||||||
if is_file {
|
|
||||||
- escaped
|
|
||||||
+ PathQuery { value: escaped, is_prefix: false }
|
|
||||||
} else {
|
|
||||||
- format!("{escaped}/%")
|
|
||||||
+ PathQuery { value: format!("{escaped}/%"), is_prefix: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
And then (example for DiffNote predicates):
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@ Query: Expert Mode @@
|
|
||||||
- let path_pattern = build_path_pattern(path);
|
|
||||||
+ let pq = build_path_query(path);
|
|
||||||
|
|
||||||
- let sql = " ... n.position_new_path LIKE ?1 ESCAPE '\\' ... ";
|
|
||||||
+ let sql_prefix = " ... n.position_new_path LIKE ?1 ESCAPE '\\' ... ";
|
|
||||||
+ let sql_exact = " ... n.position_new_path = ?1 ... ";
|
|
||||||
|
|
||||||
- let mut stmt = conn.prepare(sql)?;
|
|
||||||
+ let mut stmt = if pq.is_prefix { conn.prepare_cached(sql_prefix)? }
|
|
||||||
+ else { conn.prepare_cached(sql_exact)? };
|
|
||||||
let rows = stmt.query_map(params![... pq.value ...], ...);
|
|
||||||
|
|
||||||
Change 3 — Push Expert aggregation into SQL (less Rust, fewer rows, SQL-level LIMIT)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Right now Expert does:
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
return per-role rows
|
|
||||||
|
|
||||||
HashMap merge
|
|
||||||
|
|
||||||
score compute
|
|
||||||
|
|
||||||
sort/truncate
|
|
||||||
|
|
||||||
You can do all of that in SQL deterministically, then LIMIT ?N actually works.
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ Query: Expert Mode @@
|
|
||||||
- let sql = "SELECT username, role, activity_count, last_active_at FROM (
|
|
||||||
- ...
|
|
||||||
- )";
|
|
||||||
+ let sql = "
|
|
||||||
+ WITH activity AS (
|
|
||||||
+ SELECT
|
|
||||||
+ n.author_username AS username,
|
|
||||||
+ 'reviewer' AS role,
|
|
||||||
+ COUNT(*) AS cnt,
|
|
||||||
+ MAX(n.created_at) AS last_active_at
|
|
||||||
+ FROM notes n
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
+ AND n.is_system = 0
|
|
||||||
+ AND n.author_username IS NOT NULL
|
|
||||||
+ AND n.created_at >= ?2
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
+ AND (
|
|
||||||
+ (?4 = 1 AND n.position_new_path LIKE ?1 ESCAPE '\\') OR
|
|
||||||
+ (?4 = 0 AND n.position_new_path = ?1)
|
|
||||||
+ )
|
|
||||||
+ GROUP BY n.author_username
|
|
||||||
+
|
|
||||||
+ UNION ALL
|
|
||||||
+
|
|
||||||
+ SELECT
|
|
||||||
+ m.author_username AS username,
|
|
||||||
+ 'author' AS role,
|
|
||||||
+ COUNT(DISTINCT m.id) AS cnt,
|
|
||||||
+ MAX(n.created_at) AS last_active_at
|
|
||||||
+ FROM merge_requests m
|
|
||||||
+ JOIN discussions d ON d.merge_request_id = m.id
|
|
||||||
+ JOIN notes n ON n.discussion_id = d.id
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
+ AND n.is_system = 0
|
|
||||||
+ AND m.author_username IS NOT NULL
|
|
||||||
+ AND n.created_at >= ?2
|
|
||||||
+ AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
+ AND (
|
|
||||||
+ (?4 = 1 AND n.position_new_path LIKE ?1 ESCAPE '\\') OR
|
|
||||||
+ (?4 = 0 AND n.position_new_path = ?1)
|
|
||||||
+ )
|
|
||||||
+ GROUP BY m.author_username
|
|
||||||
+ )
|
|
||||||
+ SELECT
|
|
||||||
+ username,
|
|
||||||
+ SUM(CASE WHEN role='reviewer' THEN cnt ELSE 0 END) AS review_count,
|
|
||||||
+ SUM(CASE WHEN role='author' THEN cnt ELSE 0 END) AS author_count,
|
|
||||||
+ MAX(last_active_at) AS last_active_at,
|
|
||||||
+ (SUM(CASE WHEN role='reviewer' THEN cnt ELSE 0 END) * 3.0) +
|
|
||||||
+ (SUM(CASE WHEN role='author' THEN cnt ELSE 0 END) * 2.0) AS score
|
|
||||||
+ FROM activity
|
|
||||||
+ GROUP BY username
|
|
||||||
+ ORDER BY score DESC, last_active_at DESC, username ASC
|
|
||||||
+ LIMIT ?5
|
|
||||||
+ ";
|
|
||||||
|
|
||||||
- // Aggregate by username: combine reviewer + author counts
|
|
||||||
- let mut user_map: HashMap<...> = HashMap::new();
|
|
||||||
- ...
|
|
||||||
- experts.sort_by(...); experts.truncate(limit);
|
|
||||||
+ // No Rust-side merge/sort needed; SQL already returns final rows.
|
|
||||||
|
|
||||||
Change 4 — Overlap output is ambiguous across projects: include stable MR refs (project_path!iid)
|
|
||||||
Why
|
|
||||||
|
|
||||||
mr_iids: Vec<i64> is ambiguous in a multi-project DB. !123 only means something with a project.
|
|
||||||
|
|
||||||
Also: your MR IID dedup is currently Vec.contains() inside a loop (O(n²)). Use a HashSet.
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ OverlapResult @@
|
|
||||||
pub struct OverlapUser {
|
|
||||||
pub username: String,
|
|
||||||
@@
|
|
||||||
- pub mr_iids: Vec<i64>,
|
|
||||||
+ /// Stable MR references like "group/project!123"
|
|
||||||
+ pub mr_refs: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ Query: Overlap Mode (SQL) @@
|
|
||||||
- GROUP_CONCAT(DISTINCT m.iid) AS mr_iids
|
|
||||||
+ GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
FROM notes n
|
|
||||||
JOIN discussions d ON n.discussion_id = d.id
|
|
||||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
|
||||||
+ JOIN projects p ON m.project_id = p.id
|
|
||||||
@@
|
|
||||||
- GROUP_CONCAT(DISTINCT m.iid) AS mr_iids
|
|
||||||
+ GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
FROM merge_requests m
|
|
||||||
JOIN discussions d ON d.merge_request_id = m.id
|
|
||||||
JOIN notes n ON n.discussion_id = d.id
|
|
||||||
+ JOIN projects p ON m.project_id = p.id
|
|
||||||
|
|
||||||
@@ Query: Overlap Mode (Rust merge) @@
|
|
||||||
- let mr_iids: Vec<i64> = mr_iids_csv ...
|
|
||||||
+ let mr_refs: Vec<String> = mr_refs_csv
|
|
||||||
+ .as_deref()
|
|
||||||
+ .map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect())
|
|
||||||
+ .unwrap_or_default();
|
|
||||||
@@
|
|
||||||
- // Merge MR IIDs, deduplicate
|
|
||||||
- for iid in &mr_iids {
|
|
||||||
- if !entry.mr_iids.contains(iid) {
|
|
||||||
- entry.mr_iids.push(*iid);
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
+ // Merge MR refs, deduplicate
|
|
||||||
+ use std::collections::HashSet;
|
|
||||||
+ let mut set: HashSet<String> = entry.mr_refs.drain(..).collect();
|
|
||||||
+ for r in mr_refs { set.insert(r); }
|
|
||||||
+ entry.mr_refs = set.into_iter().collect();
|
|
||||||
|
|
||||||
Change 5 — Active mode: avoid correlated subqueries by preselecting discussions, then aggregating notes once
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your Active query does two correlated subqueries per discussion row:
|
|
||||||
|
|
||||||
note_count
|
|
||||||
|
|
||||||
participants
|
|
||||||
|
|
||||||
With LIMIT 20 it’s not catastrophic, but it is still unnecessary work and creates “spiky” behavior if the planner chooses poorly.
|
|
||||||
|
|
||||||
Pattern to use:
|
|
||||||
|
|
||||||
CTE selects the limited set of discussions
|
|
||||||
|
|
||||||
Join notes once, aggregate with GROUP BY
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ Query: Active Mode @@
|
|
||||||
- let sql =
|
|
||||||
- "SELECT
|
|
||||||
- d.noteable_type,
|
|
||||||
- ...
|
|
||||||
- (SELECT COUNT(*) FROM notes n
|
|
||||||
- WHERE n.discussion_id = d.id AND n.is_system = 0) AS note_count,
|
|
||||||
- (SELECT GROUP_CONCAT(username, X'1F') FROM (
|
|
||||||
- SELECT DISTINCT n.author_username AS username
|
|
||||||
- FROM notes n
|
|
||||||
- WHERE n.discussion_id = d.id
|
|
||||||
- AND n.is_system = 0
|
|
||||||
- AND n.author_username IS NOT NULL
|
|
||||||
- ORDER BY username
|
|
||||||
- )) AS participants
|
|
||||||
- FROM discussions d
|
|
||||||
- ...
|
|
||||||
- LIMIT ?3";
|
|
||||||
+ let sql = "
|
|
||||||
+ WITH picked AS (
|
|
||||||
+ SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, d.project_id, d.last_note_at
|
|
||||||
+ FROM discussions d
|
|
||||||
+ WHERE d.resolvable = 1 AND d.resolved = 0
|
|
||||||
+ AND d.last_note_at >= ?1
|
|
||||||
+ AND (?2 IS NULL OR d.project_id = ?2)
|
|
||||||
+ ORDER BY d.last_note_at DESC
|
|
||||||
+ LIMIT ?3
|
|
||||||
+ ),
|
|
||||||
+ note_agg AS (
|
|
||||||
+ SELECT
|
|
||||||
+ n.discussion_id,
|
|
||||||
+ COUNT(*) AS note_count,
|
|
||||||
+ GROUP_CONCAT(n.author_username, X'1F') AS participants
|
|
||||||
+ FROM (
|
|
||||||
+ SELECT DISTINCT discussion_id, author_username
|
|
||||||
+ FROM notes
|
|
||||||
+ WHERE is_system = 0 AND author_username IS NOT NULL
|
|
||||||
+ ) n
|
|
||||||
+ JOIN picked p ON p.id = n.discussion_id
|
|
||||||
+ GROUP BY n.discussion_id
|
|
||||||
+ )
|
|
||||||
+ SELECT
|
|
||||||
+ p.noteable_type,
|
|
||||||
+ COALESCE(i.iid, m.iid) AS entity_iid,
|
|
||||||
+ COALESCE(i.title, m.title) AS entity_title,
|
|
||||||
+ proj.path_with_namespace,
|
|
||||||
+ p.last_note_at,
|
|
||||||
+ COALESCE(na.note_count, 0) AS note_count,
|
|
||||||
+ COALESCE(na.participants, '') AS participants
|
|
||||||
+ FROM picked p
|
|
||||||
+ JOIN projects proj ON p.project_id = proj.id
|
|
||||||
+ LEFT JOIN issues i ON p.issue_id = i.id
|
|
||||||
+ LEFT JOIN merge_requests m ON p.merge_request_id = m.id
|
|
||||||
+ LEFT JOIN note_agg na ON na.discussion_id = p.id
|
|
||||||
+ ORDER BY p.last_note_at DESC
|
|
||||||
+ ";
|
|
||||||
|
|
||||||
Change 6 — Use prepare_cached() everywhere (cheap perf win, no scope creep)
|
|
||||||
Why
|
|
||||||
|
|
||||||
You already worked hard to keep SQL static. Taking advantage of sqlite statement caching completes the loop.
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ Query functions @@
|
|
||||||
- let mut stmt = conn.prepare(sql)?;
|
|
||||||
+ let mut stmt = conn.prepare_cached(sql)?;
|
|
||||||
|
|
||||||
|
|
||||||
Apply in all query fns (query_workload, query_reviews, query_active, query_expert, query_overlap, lookup_project_path).
|
|
||||||
|
|
||||||
Change 7 — Human output: show project_path where ambiguity exists (Workload + Overlap)
|
|
||||||
Why
|
|
||||||
|
|
||||||
When not project-scoped, #42 and !100 aren’t unique. You already have project paths in the query results — you’re just not printing them.
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ print_workload_human @@
|
|
||||||
- println!(
|
|
||||||
- " {} {} {}",
|
|
||||||
+ println!(
|
|
||||||
+ " {} {} {} {}",
|
|
||||||
style(format!("#{:<5}", item.iid)).cyan(),
|
|
||||||
truncate_str(&item.title, 45),
|
|
||||||
style(format_relative_time(item.updated_at)).dim(),
|
|
||||||
+ style(&item.project_path).dim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ print_workload_human (MRs) @@
|
|
||||||
- println!(
|
|
||||||
- " {} {}{} {}",
|
|
||||||
+ println!(
|
|
||||||
+ " {} {}{} {} {}",
|
|
||||||
style(format!("!{:<5}", mr.iid)).cyan(),
|
|
||||||
truncate_str(&mr.title, 40),
|
|
||||||
style(draft).dim(),
|
|
||||||
style(format_relative_time(mr.updated_at)).dim(),
|
|
||||||
+ style(&mr.project_path).dim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ print_overlap_human @@
|
|
||||||
- let mr_str = user.mr_iids.iter().take(5).map(|iid| format!("!{iid}")).collect::<Vec<_>>().join(", ");
|
|
||||||
+ let mr_str = user.mr_refs.iter().take(5).cloned().collect::<Vec<_>>().join(", ");
|
|
||||||
|
|
||||||
Change 8 — Robot JSON: add stable IDs + “defaulted” flags for reproducibility
|
|
||||||
Why
|
|
||||||
|
|
||||||
You already added resolved_input — good. Two more reproducibility gaps remain:
|
|
||||||
|
|
||||||
Agents can’t reliably “open” an entity without IDs (discussion_id, mr_id, issue_id).
|
|
||||||
|
|
||||||
Agents can’t tell whether since was user-provided vs defaulted (important when replaying intent).
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ WhoResolvedInput @@
|
|
||||||
pub struct WhoResolvedInput {
|
|
||||||
@@
|
|
||||||
pub since_ms: Option<i64>,
|
|
||||||
pub since_iso: Option<String>,
|
|
||||||
+ pub since_was_default: bool,
|
|
||||||
pub limit: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ run_who @@
|
|
||||||
- let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
|
||||||
+ let since_was_default = args.since.is_none();
|
|
||||||
+ let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
|
||||||
Ok(WhoRun {
|
|
||||||
resolved_input: WhoResolvedInput {
|
|
||||||
@@
|
|
||||||
since_ms: Some(since_ms),
|
|
||||||
since_iso: Some(ms_to_iso(since_ms)),
|
|
||||||
+ since_was_default,
|
|
||||||
limit: args.limit,
|
|
||||||
},
|
|
||||||
|
|
||||||
@@ print_who_json resolved_input @@
|
|
||||||
let resolved_input = serde_json::json!({
|
|
||||||
@@
|
|
||||||
"since_ms": run.resolved_input.since_ms,
|
|
||||||
"since_iso": run.resolved_input.since_iso,
|
|
||||||
+ "since_was_default": run.resolved_input.since_was_default,
|
|
||||||
"limit": run.resolved_input.limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
And for Active/Workload discussion items, add IDs in SQL and JSON:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@ ActiveDiscussion @@
|
|
||||||
pub struct ActiveDiscussion {
|
|
||||||
+ pub discussion_id: i64,
|
|
||||||
@@
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ query_active SELECT @@
|
|
||||||
- SELECT
|
|
||||||
- p.noteable_type,
|
|
||||||
+ SELECT
|
|
||||||
+ p.id AS discussion_id,
|
|
||||||
+ p.noteable_type,
|
|
||||||
|
|
||||||
@@ active_to_json @@
|
|
||||||
- "discussions": r.discussions.iter().map(|d| json!({
|
|
||||||
+ "discussions": r.discussions.iter().map(|d| json!({
|
|
||||||
+ "discussion_id": d.discussion_id,
|
|
||||||
...
|
|
||||||
}))
|
|
||||||
|
|
||||||
Change 9 — Make performance verification explicit: require EXPLAIN QUERY PLAN checks for each mode
|
|
||||||
Why
|
|
||||||
|
|
||||||
You’re adding indexes specifically for these queries. The only way to ensure the planner is doing what you think is to lock in a short perf checklist (especially after schema drift or SQLite version differences).
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
--- a/who-command-design.md
|
|
||||||
+++ b/who-command-design.md
|
|
||||||
@@ Verification @@
|
|
||||||
# Manual verification against real data
|
|
||||||
cargo run --release -- who src/features/global-search/
|
|
||||||
@@
|
|
||||||
cargo run --release -- who src/features/global-search/ -p typescript # project scoped
|
|
||||||
+
|
|
||||||
+# Perf verification (required before merge):
|
|
||||||
+# Confirm idx_notes_diffnote_path_created is used for Expert/Overlap and
|
|
||||||
+# idx_discussions_unresolved_recent is used for Active.
|
|
||||||
+sqlite3 path/to/db.sqlite "
|
|
||||||
+ EXPLAIN QUERY PLAN
|
|
||||||
+ SELECT ... -- paste final Expert SQL with representative bindings
|
|
||||||
+";
|
|
||||||
|
|
||||||
|
|
||||||
(Keep it lightweight: one representative query per mode is enough.)
|
|
||||||
|
|
||||||
Net effect
|
|
||||||
|
|
||||||
Correctness: project scoping hits the notes index; IDs added for agent workflows.
|
|
||||||
|
|
||||||
Performance: fewer rows/materialization in Expert; statement caching everywhere; Active avoids correlated subqueries.
|
|
||||||
|
|
||||||
UX: human output no longer ambiguous across projects; Overlap MR references become actionable.
|
|
||||||
|
|
||||||
Reproducibility: agents can distinguish defaults vs explicit inputs; can dereference entities reliably.
|
|
||||||
|
|
||||||
If you want one “highest ROI” subset to implement first: Change 1 + Change 4 + Change 6 + Change 7. That’s where the real operational value lands.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ChatGPT said:
|
|
||||||
Proposing code revisions for performance and determinism
|
|
||||||
Answer now
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
Below are the highest-leverage revisions I’d make. They’re tightly scoped (no new tables/APIs), but fix a few real correctness issues and make the outputs more actionable.
|
|
||||||
|
|
||||||
1) Fix a correctness bug in PathQuery: don’t escape for =, and make --path Makefile actually work
|
|
||||||
Why
|
|
||||||
|
|
||||||
Bug: build_path_query() currently runs escape_like() even when is_prefix = false (exact match). That will break exact matches for paths containing _, %, or \ because = does not treat those as metacharacters (so the escaped string won’t equal the stored path).
|
|
||||||
|
|
||||||
UX mismatch: The plan says --path handles dotless root files (Makefile/LICENSE), but the current logic still treats them as directory prefixes (Makefile/%) → zero results.
|
|
||||||
|
|
||||||
Change
|
|
||||||
|
|
||||||
Only escape for LIKE.
|
|
||||||
|
|
||||||
Treat root paths (no /) passed via --path as exact matches by default (unless they end with /).
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
-/// Build a path query from a user-supplied path.
|
|
||||||
-///
|
|
||||||
-/// Rules:
|
|
||||||
-/// - If the path ends with `/`, it's a directory prefix -> `escaped_path%` (LIKE)
|
|
||||||
-/// - If the last path segment contains `.`, it's a file -> exact match (=)
|
|
||||||
-/// - Otherwise, it's a directory prefix -> `escaped_path/%` (LIKE)
|
|
||||||
+/// Build a path query from a user-supplied path.
|
|
||||||
+///
|
|
||||||
+/// Rules:
|
|
||||||
+/// - If the path ends with `/`, it's a directory prefix -> `escaped_path/%` (LIKE)
|
|
||||||
+/// - If the path is a root path (no `/`) and does NOT end with `/`, treat as exact (=)
|
|
||||||
+/// (this makes `--path Makefile` and `--path LICENSE` work as intended)
|
|
||||||
+/// - Else if the last path segment contains `.`, treat as exact (=)
|
|
||||||
+/// - Otherwise, treat as directory prefix -> `escaped_path/%` (LIKE)
|
|
||||||
@@
|
|
||||||
-fn build_path_query(path: &str) -> PathQuery {
|
|
||||||
+fn build_path_query(path: &str) -> PathQuery {
|
|
||||||
let trimmed = path.trim_end_matches('/');
|
|
||||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
||||||
- let is_file = !path.ends_with('/') && last_segment.contains('.');
|
|
||||||
- let escaped = escape_like(trimmed);
|
|
||||||
+ let is_root = !trimmed.contains('/');
|
|
||||||
+ let is_file = !path.ends_with('/') && (is_root || last_segment.contains('.'));
|
|
||||||
|
|
||||||
if is_file {
|
|
||||||
PathQuery {
|
|
||||||
- value: escaped,
|
|
||||||
+ // IMPORTANT: do NOT escape for exact match (=)
|
|
||||||
+ value: trimmed.to_string(),
|
|
||||||
is_prefix: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
+ let escaped = escape_like(trimmed);
|
|
||||||
PathQuery {
|
|
||||||
value: format!("{escaped}/%"),
|
|
||||||
is_prefix: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
-/// **Known limitation:** Dotless root files (LICENSE, Makefile, Dockerfile)
|
|
||||||
-/// without a trailing `/` will be treated as directory prefixes. Use `--path`
|
|
||||||
-/// for these — the `--path` flag passes through to Expert mode directly,
|
|
||||||
-/// and the `build_path_query` output for "LICENSE" is a prefix `LICENSE/%`
|
|
||||||
-/// which will simply return zero results (a safe, obvious failure mode that the
|
|
||||||
-/// help text addresses).
|
|
||||||
+/// Note: Root file paths passed via `--path` (including dotless files like Makefile/LICENSE)
|
|
||||||
+/// are treated as exact matches unless they end with `/`.
|
|
||||||
|
|
||||||
|
|
||||||
Also update the --path help text to be explicit:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
- /// Force expert mode for a file/directory path (handles root files like
|
|
||||||
- /// README.md, LICENSE, Makefile that lack a / and can't be auto-detected)
|
|
||||||
+ /// Force expert mode for a file/directory path.
|
|
||||||
+ /// Root files (README.md, LICENSE, Makefile) are treated as exact matches.
|
|
||||||
+ /// Use a trailing `/` to force directory-prefix matching.
|
|
||||||
|
|
||||||
2) Fix Active mode: your note_count is currently counting participants, and the CTE scans too broadly
|
|
||||||
Why
|
|
||||||
|
|
||||||
In note_agg, you do SELECT DISTINCT discussion_id, author_username and then COUNT(*) AS note_count. That’s participant count, not note count.
|
|
||||||
|
|
||||||
The current note_agg also builds the DISTINCT set from all notes then joins to picked. It’s avoidable work.
|
|
||||||
|
|
||||||
Change
|
|
||||||
|
|
||||||
Split into two aggregations scoped to picked:
|
|
||||||
|
|
||||||
note_counts: counts non-system notes per picked discussion.
|
|
||||||
|
|
||||||
participants: distinct usernames per picked discussion, then GROUP_CONCAT.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
- note_agg AS (
|
|
||||||
- SELECT
|
|
||||||
- n.discussion_id,
|
|
||||||
- COUNT(*) AS note_count,
|
|
||||||
- GROUP_CONCAT(n.author_username, X'1F') AS participants
|
|
||||||
- FROM (
|
|
||||||
- SELECT DISTINCT discussion_id, author_username
|
|
||||||
- FROM notes
|
|
||||||
- WHERE is_system = 0 AND author_username IS NOT NULL
|
|
||||||
- ) n
|
|
||||||
- JOIN picked p ON p.id = n.discussion_id
|
|
||||||
- GROUP BY n.discussion_id
|
|
||||||
- )
|
|
||||||
+ note_counts AS (
|
|
||||||
+ SELECT
|
|
||||||
+ n.discussion_id,
|
|
||||||
+ COUNT(*) AS note_count
|
|
||||||
+ FROM notes n
|
|
||||||
+ JOIN picked p ON p.id = n.discussion_id
|
|
||||||
+ WHERE n.is_system = 0
|
|
||||||
+ GROUP BY n.discussion_id
|
|
||||||
+ ),
|
|
||||||
+ participants AS (
|
|
||||||
+ SELECT
|
|
||||||
+ x.discussion_id,
|
|
||||||
+ GROUP_CONCAT(x.author_username, X'1F') AS participants
|
|
||||||
+ FROM (
|
|
||||||
+ SELECT DISTINCT n.discussion_id, n.author_username
|
|
||||||
+ FROM notes n
|
|
||||||
+ JOIN picked p ON p.id = n.discussion_id
|
|
||||||
+ WHERE n.is_system = 0 AND n.author_username IS NOT NULL
|
|
||||||
+ ) x
|
|
||||||
+ GROUP BY x.discussion_id
|
|
||||||
+ )
|
|
||||||
@@
|
|
||||||
- LEFT JOIN note_agg na ON na.discussion_id = p.id
|
|
||||||
+ LEFT JOIN note_counts nc ON nc.discussion_id = p.id
|
|
||||||
+ LEFT JOIN participants pa ON pa.discussion_id = p.id
|
|
||||||
@@
|
|
||||||
- COALESCE(na.note_count, 0) AS note_count,
|
|
||||||
- COALESCE(na.participants, '') AS participants
|
|
||||||
+ COALESCE(nc.note_count, 0) AS note_count,
|
|
||||||
+ COALESCE(pa.participants, '') AS participants
|
|
||||||
|
|
||||||
|
|
||||||
Net effect: correctness fix + more predictable perf.
|
|
||||||
|
|
||||||
Add a test that would have failed before:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
#[test]
|
|
||||||
fn test_active_query() {
|
|
||||||
@@
|
|
||||||
- insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work");
|
|
||||||
+ insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work");
|
|
||||||
+ insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up");
|
|
||||||
@@
|
|
||||||
- assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
|
|
||||||
+ assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
|
|
||||||
+ assert_eq!(result.discussions[0].note_count, 2);
|
|
||||||
|
|
||||||
3) Index fix: idx_discussions_unresolved_recent won’t help global --active ordering
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your index is (project_id, last_note_at) with WHERE resolvable=1 AND resolved=0.
|
|
||||||
|
|
||||||
When --active is not project-scoped (common default), SQLite can’t use (project_id, last_note_at) to satisfy ORDER BY last_note_at DESC efficiently because project_id isn’t constrained.
|
|
||||||
|
|
||||||
This can turn into a scan+sort over potentially large unresolved sets.
|
|
||||||
|
|
||||||
Change
|
|
||||||
|
|
||||||
Keep the project-scoped index, but add a global ordering index (partial, still small):
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent
|
|
||||||
ON discussions(project_id, last_note_at)
|
|
||||||
WHERE resolvable = 1 AND resolved = 0;
|
|
||||||
+
|
|
||||||
+-- Active (global): unresolved discussions by recency (no project scope).
|
|
||||||
+-- Supports ORDER BY last_note_at DESC LIMIT N when project_id is unconstrained.
|
|
||||||
+CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent_global
|
|
||||||
+ ON discussions(last_note_at)
|
|
||||||
+ WHERE resolvable = 1 AND resolved = 0;
|
|
||||||
|
|
||||||
4) Make Overlap “touches” coherent: count MRs for reviewers, not DiffNotes
|
|
||||||
Why
|
|
||||||
|
|
||||||
Overlap’s question is “Who else has MRs touching my files?” but:
|
|
||||||
|
|
||||||
reviewer branch uses COUNT(*) (DiffNotes)
|
|
||||||
|
|
||||||
author branch uses COUNT(DISTINCT m.id) (MRs)
|
|
||||||
|
|
||||||
Those are different units; summing them into touch_count is misleading.
|
|
||||||
|
|
||||||
Change
|
|
||||||
|
|
||||||
Count distinct MRs on the reviewer branch too:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
- COUNT(*) AS touch_count,
|
|
||||||
+ COUNT(DISTINCT m.id) AS touch_count,
|
|
||||||
MAX(n.created_at) AS last_touch_at,
|
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
|
|
||||||
|
|
||||||
Also update human output labeling:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
- style("Touches").bold(),
|
|
||||||
+ style("MRs").bold(),
|
|
||||||
|
|
||||||
|
|
||||||
(You still preserve “strength” via mr_refs and last_touch_at.)
|
|
||||||
|
|
||||||
5) Make outputs more actionable: add a canonical ref field (group/project!iid, group/project#iid)
|
|
||||||
Why
|
|
||||||
|
|
||||||
You already do this for Overlap (mr_refs). Doing the same for Workload and Active reduces friction for both humans and agents:
|
|
||||||
|
|
||||||
humans can copy/paste a single token
|
|
||||||
|
|
||||||
robots don’t need to stitch project_path + iid + prefix
|
|
||||||
|
|
||||||
Change (Workload structs + SQL)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
pub struct WorkloadIssue {
|
|
||||||
pub iid: i64,
|
|
||||||
+ pub ref_: String,
|
|
||||||
pub title: String,
|
|
||||||
pub project_path: String,
|
|
||||||
pub updated_at: i64,
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
pub struct WorkloadMr {
|
|
||||||
pub iid: i64,
|
|
||||||
+ pub ref_: String,
|
|
||||||
pub title: String,
|
|
||||||
pub draft: bool,
|
|
||||||
pub project_path: String,
|
|
||||||
@@
|
|
||||||
- let issues_sql =
|
|
||||||
- "SELECT i.iid, i.title, p.path_with_namespace, i.updated_at
|
|
||||||
+ let issues_sql =
|
|
||||||
+ "SELECT i.iid,
|
|
||||||
+ (p.path_with_namespace || '#' || i.iid) AS ref,
|
|
||||||
+ i.title, p.path_with_namespace, i.updated_at
|
|
||||||
@@
|
|
||||||
- iid: row.get(0)?,
|
|
||||||
- title: row.get(1)?,
|
|
||||||
- project_path: row.get(2)?,
|
|
||||||
- updated_at: row.get(3)?,
|
|
||||||
+ iid: row.get(0)?,
|
|
||||||
+ ref_: row.get(1)?,
|
|
||||||
+ title: row.get(2)?,
|
|
||||||
+ project_path: row.get(3)?,
|
|
||||||
+ updated_at: row.get(4)?,
|
|
||||||
})
|
|
||||||
@@
|
|
||||||
- let authored_sql =
|
|
||||||
- "SELECT m.iid, m.title, m.draft, p.path_with_namespace, m.updated_at
|
|
||||||
+ let authored_sql =
|
|
||||||
+ "SELECT m.iid,
|
|
||||||
+ (p.path_with_namespace || '!' || m.iid) AS ref,
|
|
||||||
+ m.title, m.draft, p.path_with_namespace, m.updated_at
|
|
||||||
@@
|
|
||||||
- iid: row.get(0)?,
|
|
||||||
- title: row.get(1)?,
|
|
||||||
- draft: row.get::<_, i32>(2)? != 0,
|
|
||||||
- project_path: row.get(3)?,
|
|
||||||
+ iid: row.get(0)?,
|
|
||||||
+ ref_: row.get(1)?,
|
|
||||||
+ title: row.get(2)?,
|
|
||||||
+ draft: row.get::<_, i32>(3)? != 0,
|
|
||||||
+ project_path: row.get(4)?,
|
|
||||||
author_username: None,
|
|
||||||
- updated_at: row.get(4)?,
|
|
||||||
+ updated_at: row.get(5)?,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
Then use ref_ in human output + robot JSON.
|
|
||||||
|
|
||||||
6) Reviews mode: tolerate leading whitespace before **prefix**
|
|
||||||
Why
|
|
||||||
|
|
||||||
Many people write " **suggestion**: ...". Current LIKE '**%**%' misses that.
|
|
||||||
|
|
||||||
Change
|
|
||||||
|
|
||||||
Use ltrim(n.body) consistently:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
- AND n.body LIKE '**%**%'
|
|
||||||
+ AND ltrim(n.body) LIKE '**%**%'
|
|
||||||
@@
|
|
||||||
- SUBSTR(n.body, 3, INSTR(SUBSTR(n.body, 3), '**') - 1) AS raw_prefix,
|
|
||||||
+ SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
|
|
||||||
|
|
||||||
7) Add two small tests that catch the above regressions
|
|
||||||
Why
|
|
||||||
|
|
||||||
These are exactly the kind of issues that slip through without targeted tests.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/plan.md b/plan.md
|
|
||||||
@@
|
|
||||||
#[test]
|
|
||||||
fn test_escape_like() {
|
|
||||||
@@
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ #[test]
|
|
||||||
+ fn test_build_path_query_exact_does_not_escape() {
|
|
||||||
+ // '_' must not be escaped for '='
|
|
||||||
+ let pq = build_path_query("README_with_underscore.md");
|
|
||||||
+ assert_eq!(pq.value, "README_with_underscore.md");
|
|
||||||
+ assert!(!pq.is_prefix);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ #[test]
|
|
||||||
+ fn test_path_flag_dotless_root_file_is_exact() {
|
|
||||||
+ let pq = build_path_query("Makefile");
|
|
||||||
+ assert_eq!(pq.value, "Makefile");
|
|
||||||
+ assert!(!pq.is_prefix);
|
|
||||||
+ }
|
|
||||||
|
|
||||||
Summary of net effect
|
|
||||||
|
|
||||||
Correctness fixes: exact-path escaping bug; Active.note_count bug.
|
|
||||||
|
|
||||||
Perf fixes: global --active index; avoid broad note scans in Active.
|
|
||||||
|
|
||||||
Usefulness upgrades: coherent overlap “touch” metric; canonical refs everywhere; reviews prefix more robust.
|
|
||||||
|
|
||||||
If you want one extra “stretch” that still isn’t scope creep: add an unscoped warning line in human output when project_id == None (e.g., “Aggregated across projects; use -p to scope”) for Expert/Overlap/Active. That’s pure presentation, but prevents misinterpretation in multi-project DBs.
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
Proposed revisions (Iteration 6)
|
|
||||||
|
|
||||||
Below are the highest-leverage changes I’d make on top of your current Iteration 5 plan, with rationale and git-diff style edits to the plan text/snippets.
|
|
||||||
|
|
||||||
1) Fix a real edge case: dotless non-root files (src/Dockerfile, infra/Makefile, etc.)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your current build_path_query() treats dotless last segments as directories (prefix match) unless the path is root. That misclassifies legitimate dotless files inside directories and silently produces path/% (zero hits or wrong hits).
|
|
||||||
|
|
||||||
Best minimal fix: keep your static SQL approach, but add a DB existence probe (static SQL) for path queries:
|
|
||||||
|
|
||||||
If user didn’t force directory (/), and exact path exists in DiffNotes, treat as exact =.
|
|
||||||
|
|
||||||
Otherwise use prefix LIKE 'dir/%'.
|
|
||||||
|
|
||||||
This avoids new CLI flags, avoids heuristics lists, and uses your existing partial index (idx_notes_diffnote_path_created) efficiently.
|
|
||||||
|
|
||||||
Diff
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/Plan.md b/Plan.md
|
|
||||||
@@
|
|
||||||
-struct PathQuery {
|
|
||||||
+struct PathQuery {
|
|
||||||
/// The parameter value to bind.
|
|
||||||
value: String,
|
|
||||||
/// If true: use `LIKE value ESCAPE '\'`. If false: use `= value`.
|
|
||||||
is_prefix: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
-/// Build a path query from a user-supplied path.
|
|
||||||
+/// Build a path query from a user-supplied path, with a DB probe for dotless files.
|
|
||||||
@@
|
|
||||||
-fn build_path_query(path: &str) -> PathQuery {
|
|
||||||
+fn build_path_query(conn: &Connection, path: &str) -> Result<PathQuery> {
|
|
||||||
let trimmed = path.trim_end_matches('/');
|
|
||||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
||||||
let is_root = !trimmed.contains('/');
|
|
||||||
- let is_file = !path.ends_with('/') && (is_root || last_segment.contains('.'));
|
|
||||||
+ let forced_dir = path.ends_with('/');
|
|
||||||
+ let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
|
||||||
+
|
|
||||||
+ // If it doesn't "look like a file" but the exact path exists in DiffNotes,
|
|
||||||
+ // treat as exact (handles src/Dockerfile, infra/Makefile, etc.).
|
|
||||||
+ let exact_exists = if !looks_like_file && !forced_dir {
|
|
||||||
+ conn.query_row(
|
|
||||||
+ "SELECT 1
|
|
||||||
+ FROM notes
|
|
||||||
+ WHERE note_type = 'DiffNote'
|
|
||||||
+ AND is_system = 0
|
|
||||||
+ AND position_new_path = ?1
|
|
||||||
+ LIMIT 1",
|
|
||||||
+ rusqlite::params![trimmed],
|
|
||||||
+ |_| Ok(()),
|
|
||||||
+ ).is_ok()
|
|
||||||
+ } else {
|
|
||||||
+ false
|
|
||||||
+ };
|
|
||||||
+
|
|
||||||
+ let is_file = looks_like_file || exact_exists;
|
|
||||||
|
|
||||||
if is_file {
|
|
||||||
PathQuery {
|
|
||||||
value: trimmed.to_string(),
|
|
||||||
is_prefix: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let escaped = escape_like(trimmed);
|
|
||||||
PathQuery {
|
|
||||||
value: format!("{escaped}/%"),
|
|
||||||
is_prefix: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Also update callers:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- let pq = build_path_query(path);
|
|
||||||
+ let pq = build_path_query(conn, path)?;
|
|
||||||
@@
|
|
||||||
- let pq = build_path_query(path);
|
|
||||||
+ let pq = build_path_query(conn, path)?;
|
|
||||||
|
|
||||||
|
|
||||||
And tests:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- fn test_build_path_query() {
|
|
||||||
+ fn test_build_path_query() {
|
|
||||||
@@
|
|
||||||
- // Dotless root file -> exact match (root path without '/')
|
|
||||||
+ // Dotless root file -> exact match (root path without '/')
|
|
||||||
let pq = build_path_query("Makefile");
|
|
||||||
assert_eq!(pq.value, "Makefile");
|
|
||||||
assert!(!pq.is_prefix);
|
|
||||||
+
|
|
||||||
+ // Dotless file in subdir should become exact if DB contains it (probe)
|
|
||||||
+ // (set up: insert one DiffNote with position_new_path = "src/Dockerfile")
|
|
||||||
|
|
||||||
2) Make “reviewer” semantics correct: exclude MR authors commenting on their own diffs
|
|
||||||
Why
|
|
||||||
|
|
||||||
Right now, Overlap (and Expert reviewer branch) will count MR authors as “reviewers” if they leave DiffNotes in their own MR (clarifications / replies), inflating A+R and contaminating “who reviewed here” signals.
|
|
||||||
|
|
||||||
You already enforce this in --reviews mode (m.author_username != ?1). Apply the same principle consistently:
|
|
||||||
|
|
||||||
Reviewer branch: only count notes where n.author_username != m.author_username (when both non-NULL).
|
|
||||||
|
|
||||||
Diff (Overlap reviewer branch)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- WHERE n.note_type = 'DiffNote'
|
|
||||||
+ WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
AND n.is_system = 0
|
|
||||||
AND n.author_username IS NOT NULL
|
|
||||||
+ AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
|
|
||||||
|
|
||||||
Same change for sql_exact.
|
|
||||||
|
|
||||||
3) Expert mode scoring: align units + reduce single-MR “comment storms”
|
|
||||||
Why
|
|
||||||
|
|
||||||
Expert currently mixes units:
|
|
||||||
|
|
||||||
reviewer side: DiffNote count
|
|
||||||
|
|
||||||
author side: distinct MR count
|
|
||||||
|
|
||||||
That makes score noisy and can crown “someone who wrote 30 comments on one MR” as top expert.
|
|
||||||
|
|
||||||
Fix: make both sides primarily MR-breadth:
|
|
||||||
|
|
||||||
reviewer: COUNT(DISTINCT m.id) as review_mr_count
|
|
||||||
|
|
||||||
author: COUNT(DISTINCT m.id) as author_mr_count
|
|
||||||
Optionally keep review_note_count as a secondary intensity signal (but not the main driver).
|
|
||||||
|
|
||||||
Diff (types + SQL)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
pub struct Expert {
|
|
||||||
pub username: String,
|
|
||||||
- pub score: f64,
|
|
||||||
- pub review_count: u32,
|
|
||||||
- pub author_count: u32,
|
|
||||||
+ pub score: i64,
|
|
||||||
+ pub review_mr_count: u32,
|
|
||||||
+ pub review_note_count: u32,
|
|
||||||
+ pub author_mr_count: u32,
|
|
||||||
pub last_active_ms: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Reviewer branch now joins to MR so it can count distinct MRs and exclude self-comments:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- SELECT
|
|
||||||
- n.author_username AS username,
|
|
||||||
- 'reviewer' AS role,
|
|
||||||
- COUNT(*) AS cnt,
|
|
||||||
- MAX(n.created_at) AS last_active_at
|
|
||||||
- FROM notes n
|
|
||||||
+ SELECT
|
|
||||||
+ n.author_username AS username,
|
|
||||||
+ 'reviewer' AS role,
|
|
||||||
+ COUNT(DISTINCT m.id) AS mr_cnt,
|
|
||||||
+ COUNT(*) AS note_cnt,
|
|
||||||
+ MAX(n.created_at) AS last_active_at
|
|
||||||
+ FROM notes n
|
|
||||||
+ JOIN discussions d ON n.discussion_id = d.id
|
|
||||||
+ JOIN merge_requests m ON d.merge_request_id = m.id
|
|
||||||
WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.is_system = 0
|
|
||||||
AND n.author_username IS NOT NULL
|
|
||||||
+ AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
|
||||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY n.author_username
|
|
||||||
|
|
||||||
|
|
||||||
Update author branch payload to match shape:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
SELECT
|
|
||||||
m.author_username AS username,
|
|
||||||
'author' AS role,
|
|
||||||
- COUNT(DISTINCT m.id) AS cnt,
|
|
||||||
+ COUNT(DISTINCT m.id) AS mr_cnt,
|
|
||||||
+ 0 AS note_cnt,
|
|
||||||
MAX(n.created_at) AS last_active_at
|
|
||||||
|
|
||||||
|
|
||||||
Aggregate:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
SELECT
|
|
||||||
username,
|
|
||||||
- SUM(CASE WHEN role = 'reviewer' THEN cnt ELSE 0 END) AS review_count,
|
|
||||||
- SUM(CASE WHEN role = 'author' THEN cnt ELSE 0 END) AS author_count,
|
|
||||||
+ SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) AS review_mr_count,
|
|
||||||
+ SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) AS review_note_count,
|
|
||||||
+ SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) AS author_mr_count,
|
|
||||||
MAX(last_active_at) AS last_active_at,
|
|
||||||
- (SUM(CASE WHEN role = 'reviewer' THEN cnt ELSE 0 END) * 3.0) +
|
|
||||||
- (SUM(CASE WHEN role = 'author' THEN cnt ELSE 0 END) * 2.0) AS score
|
|
||||||
+ (
|
|
||||||
+ (SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) * 20) +
|
|
||||||
+ (SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) * 12) +
|
|
||||||
+ (SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) * 1)
|
|
||||||
+ ) AS score
|
|
||||||
|
|
||||||
|
|
||||||
Human header:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- style("Reviews").bold(),
|
|
||||||
- style("Authored").bold(),
|
|
||||||
+ style("Reviewed(MRs)").bold(),
|
|
||||||
+ style("Notes").bold(),
|
|
||||||
+ style("Authored(MRs)").bold(),
|
|
||||||
|
|
||||||
4) Deterministic output: participants + MR refs + tie-breakers
|
|
||||||
Why
|
|
||||||
|
|
||||||
You’ve correctly focused on reproducibility (resolved_input), but you still have nondeterministic lists:
|
|
||||||
|
|
||||||
participants: GROUP_CONCAT order is undefined → vector order changes run-to-run.
|
|
||||||
|
|
||||||
mr_refs: you dedup via HashSet then iterate → undefined order.
|
|
||||||
|
|
||||||
user sorting in overlap is missing stable tie-breakers.
|
|
||||||
|
|
||||||
This is a real “robot mode flake” source.
|
|
||||||
|
|
||||||
Diff (Active participants sort)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- let participants: Vec<String> = participants_csv
|
|
||||||
+ let mut participants: Vec<String> = participants_csv
|
|
||||||
.as_deref()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|csv| csv.split('\x1F').map(String::from).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
+ participants.sort(); // stable, deterministic
|
|
||||||
|
|
||||||
Diff (Overlap MR refs sort + stable user sort)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- users.sort_by(|a, b| b.touch_count.cmp(&a.touch_count));
|
|
||||||
+ users.sort_by(|a, b| {
|
|
||||||
+ b.touch_count.cmp(&a.touch_count)
|
|
||||||
+ .then_with(|| b.last_touch_at.cmp(&a.last_touch_at))
|
|
||||||
+ .then_with(|| a.username.cmp(&b.username))
|
|
||||||
+ });
|
|
||||||
@@
|
|
||||||
- entry.mr_refs = set.into_iter().collect();
|
|
||||||
+ let mut v: Vec<String> = set.into_iter().collect();
|
|
||||||
+ v.sort();
|
|
||||||
+ entry.mr_refs = v;
|
|
||||||
|
|
||||||
5) Make --limit actionable: surface truncation explicitly (human + robot)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Agents (and humans) need to know if results were cut off so they can rerun with a bigger -n.
|
|
||||||
Right now there’s no signal.
|
|
||||||
|
|
||||||
Minimal pattern: query limit + 1, set truncated = true if you got > limit, then truncate.
|
|
||||||
|
|
||||||
Diff (result types)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
pub struct ExpertResult {
|
|
||||||
pub path_query: String,
|
|
||||||
pub experts: Vec<Expert>,
|
|
||||||
+ pub truncated: bool,
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
pub struct ActiveResult {
|
|
||||||
pub discussions: Vec<ActiveDiscussion>,
|
|
||||||
pub total_unresolved: u32,
|
|
||||||
+ pub truncated: bool,
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
pub struct OverlapResult {
|
|
||||||
pub path_query: String,
|
|
||||||
pub users: Vec<OverlapUser>,
|
|
||||||
+ pub truncated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
Diff (query pattern example)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- let limit_i64 = limit as i64;
|
|
||||||
+ let limit_plus_one = (limit + 1) as i64;
|
|
||||||
@@
|
|
||||||
- LIMIT ?4
|
|
||||||
+ LIMIT ?4
|
|
||||||
@@
|
|
||||||
- rusqlite::params![pq.value, since_ms, project_id, limit_i64],
|
|
||||||
+ rusqlite::params![pq.value, since_ms, project_id, limit_plus_one],
|
|
||||||
@@
|
|
||||||
- Ok(ExpertResult {
|
|
||||||
+ let truncated = experts.len() > limit;
|
|
||||||
+ let experts = experts.into_iter().take(limit).collect();
|
|
||||||
+ Ok(ExpertResult {
|
|
||||||
path_query: path.to_string(),
|
|
||||||
experts,
|
|
||||||
+ truncated,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
Human output hint:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
if r.experts.is_empty() { ... }
|
|
||||||
+ if r.truncated {
|
|
||||||
+ println!(" {}", style("(showing first -n; rerun with a higher --limit)").dim());
|
|
||||||
+ }
|
|
||||||
|
|
||||||
|
|
||||||
Robot output field:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
fn expert_to_json(r: &ExpertResult) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"path_query": r.path_query,
|
|
||||||
+ "truncated": r.truncated,
|
|
||||||
"experts": ...
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
6) Overlap merge hot loop: avoid repeated HashSet rebuild per row
|
|
||||||
Why
|
|
||||||
|
|
||||||
This line is expensive in a UNION result with many rows:
|
|
||||||
|
|
||||||
rust
|
|
||||||
Copy code
|
|
||||||
let mut set: HashSet<String> = entry.mr_refs.drain(..).collect();
|
|
||||||
|
|
||||||
|
|
||||||
It reallocates and rehashes every time.
|
|
||||||
|
|
||||||
Fix: store an accumulator with HashSet during merge, convert once at end.
|
|
||||||
|
|
||||||
Diff (internal accumulator)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
- let mut user_map: HashMap<String, OverlapUser> = HashMap::new();
|
|
||||||
+ struct OverlapAcc {
|
|
||||||
+ username: String,
|
|
||||||
+ author_touch_count: u32,
|
|
||||||
+ review_touch_count: u32,
|
|
||||||
+ touch_count: u32,
|
|
||||||
+ last_touch_at: i64,
|
|
||||||
+ mr_refs: HashSet<String>,
|
|
||||||
+ }
|
|
||||||
+ let mut user_map: HashMap<String, OverlapAcc> = HashMap::new();
|
|
||||||
@@
|
|
||||||
- let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapUser {
|
|
||||||
+ let entry = user_map.entry(username.clone()).or_insert_with(|| OverlapAcc {
|
|
||||||
username: username.clone(),
|
|
||||||
author_touch_count: 0,
|
|
||||||
review_touch_count: 0,
|
|
||||||
touch_count: 0,
|
|
||||||
last_touch_at: 0,
|
|
||||||
- mr_refs: Vec::new(),
|
|
||||||
+ mr_refs: HashSet::new(),
|
|
||||||
});
|
|
||||||
@@
|
|
||||||
- let mut set: HashSet<String> = entry.mr_refs.drain(..).collect();
|
|
||||||
- for r in mr_refs { set.insert(r); }
|
|
||||||
- entry.mr_refs = set.into_iter().collect();
|
|
||||||
+ for r in mr_refs { entry.mr_refs.insert(r); }
|
|
||||||
@@
|
|
||||||
- let mut users: Vec<OverlapUser> = user_map.into_values().collect();
|
|
||||||
+ let mut users: Vec<OverlapUser> = user_map.into_values().map(|a| {
|
|
||||||
+ let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
|
|
||||||
+ mr_refs.sort();
|
|
||||||
+ OverlapUser {
|
|
||||||
+ username: a.username,
|
|
||||||
+ author_touch_count: a.author_touch_count,
|
|
||||||
+ review_touch_count: a.review_touch_count,
|
|
||||||
+ touch_count: a.touch_count,
|
|
||||||
+ last_touch_at: a.last_touch_at,
|
|
||||||
+ mr_refs,
|
|
||||||
+ }
|
|
||||||
+ }).collect();
|
|
||||||
|
|
||||||
7) Tests to lock these behaviors
|
|
||||||
Add tests (high value)
|
|
||||||
|
|
||||||
dotless subdir file uses DB probe → exact match
|
|
||||||
|
|
||||||
self-review exclusion prevents MR author showing up as reviewer
|
|
||||||
|
|
||||||
deterministic ordering for participants and mr_refs (sort)
|
|
||||||
|
|
||||||
Diff (test additions outline)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
@@
|
|
||||||
#[test]
|
|
||||||
+ fn test_build_path_query_dotless_subdir_file_uses_probe() {
|
|
||||||
+ let conn = setup_test_db();
|
|
||||||
+ insert_project(&conn, 1, "team/backend");
|
|
||||||
+ insert_mr(&conn, 1, 1, 100, "author_a", "opened");
|
|
||||||
+ insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
|
||||||
+ insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/Dockerfile", "note");
|
|
||||||
+
|
|
||||||
+ let pq = build_path_query(&conn, "src/Dockerfile").unwrap();
|
|
||||||
+ assert_eq!(pq.value, "src/Dockerfile");
|
|
||||||
+ assert!(!pq.is_prefix);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ #[test]
|
|
||||||
+ fn test_overlap_excludes_self_review_notes() {
|
|
||||||
+ let conn = setup_test_db();
|
|
||||||
+ insert_project(&conn, 1, "team/backend");
|
|
||||||
+ insert_mr(&conn, 1, 1, 100, "author_a", "opened");
|
|
||||||
+ insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
|
||||||
+ // author_a comments on their own MR diff
|
|
||||||
+ insert_diffnote(&conn, 1, 1, 1, "author_a", "src/auth/login.rs", "clarification");
|
|
||||||
+
|
|
||||||
+ let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
|
|
||||||
+ let u = result.users.iter().find(|u| u.username == "author_a");
|
|
||||||
+ // should not be credited as reviewer touch
|
|
||||||
+ assert!(u.map(|x| x.review_touch_count).unwrap_or(0) == 0);
|
|
||||||
+ }
|
|
||||||
|
|
||||||
Net effect
|
|
||||||
|
|
||||||
Correctness: fixes dotless subdir files + self-review pollution.
|
|
||||||
|
|
||||||
Signal quality: Expert ranking becomes harder to game by comment volume.
|
|
||||||
|
|
||||||
Robot reproducibility: deterministic ordering + explicit truncation.
|
|
||||||
|
|
||||||
Performance: avoids rehash loops in overlap merges; path probe uses indexed equality.
|
|
||||||
|
|
||||||
If you want one “single best” change: #1 (DB probe exact-match) is the most likely to prevent confusing “why is this empty?” behavior without adding any user-facing complexity.
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
Below are the highest-leverage revisions I’d make to iteration 6 to improve correctness (multi-project edge cases), robot-mode reliability (bounded payloads + truncation), and signal quality—without changing the fundamental scope (still pure SQL over existing tables).
|
|
||||||
|
|
||||||
1) Make build_path_query project-aware and two-way probe (exact and prefix)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Your DB probe currently answers: “does this exact file exist anywhere in DiffNotes?” That can misclassify in a project-scoped run:
|
|
||||||
|
|
||||||
Path exists as a dotless file in Project A → probe returns true
|
|
||||||
|
|
||||||
User runs -p Project B where the path is a directory (or different shape) → you switch to exact, return empty, and miss valid prefix hits.
|
|
||||||
|
|
||||||
Also, you still have a minor heuristic fragility for dot directories when the user omits trailing / (e.g., .github/workflows): last segment has a dot → you treat as file unless forced dir.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Thread project_id into build_path_query(conn, path, project_id)
|
|
||||||
|
|
||||||
Probe exact first (scoped), then probe prefix (scoped)
|
|
||||||
|
|
||||||
Only fall back to heuristics if both probes fail
|
|
||||||
|
|
||||||
This keeps “static SQL, no dynamic assembly,” and costs at most 2 indexed existence queries per invocation.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
- fn build_path_query(conn: &Connection, path: &str) -> Result<PathQuery> {
|
|
||||||
+ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> Result<PathQuery> {
|
|
||||||
let trimmed = path.trim_end_matches('/');
|
|
||||||
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
|
||||||
let is_root = !trimmed.contains('/');
|
|
||||||
let forced_dir = path.ends_with('/');
|
|
||||||
- let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
|
||||||
+ // Heuristic is now only a fallback; probes decide first.
|
|
||||||
+ let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
|
||||||
|
|
||||||
- let exact_exists = if !looks_like_file && !forced_dir {
|
|
||||||
- conn.query_row(
|
|
||||||
- "SELECT 1 FROM notes
|
|
||||||
- WHERE note_type = 'DiffNote'
|
|
||||||
- AND is_system = 0
|
|
||||||
- AND position_new_path = ?1
|
|
||||||
- LIMIT 1",
|
|
||||||
- rusqlite::params![trimmed],
|
|
||||||
- |_| Ok(()),
|
|
||||||
- )
|
|
||||||
- .is_ok()
|
|
||||||
- } else {
|
|
||||||
- false
|
|
||||||
- };
|
|
||||||
+ // Probe 1: exact file exists (scoped)
|
|
||||||
+ let exact_exists = conn.query_row(
|
|
||||||
+ "SELECT 1 FROM notes
|
|
||||||
+ WHERE note_type = 'DiffNote'
|
|
||||||
+ AND is_system = 0
|
|
||||||
+ AND position_new_path = ?1
|
|
||||||
+ AND (?2 IS NULL OR project_id = ?2)
|
|
||||||
+ LIMIT 1",
|
|
||||||
+ rusqlite::params![trimmed, project_id],
|
|
||||||
+ |_| Ok(()),
|
|
||||||
+ ).is_ok();
|
|
||||||
+
|
|
||||||
+ // Probe 2: directory prefix exists (scoped)
|
|
||||||
+ let prefix_exists = if !forced_dir {
|
|
||||||
+ let escaped = escape_like(trimmed);
|
|
||||||
+ let pat = format!("{escaped}/%");
|
|
||||||
+ conn.query_row(
|
|
||||||
+ "SELECT 1 FROM notes
|
|
||||||
+ WHERE note_type = 'DiffNote'
|
|
||||||
+ AND is_system = 0
|
|
||||||
+ AND position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
+ AND (?2 IS NULL OR project_id = ?2)
|
|
||||||
+ LIMIT 1",
|
|
||||||
+ rusqlite::params![pat, project_id],
|
|
||||||
+ |_| Ok(()),
|
|
||||||
+ ).is_ok()
|
|
||||||
+ } else { false };
|
|
||||||
|
|
||||||
- let is_file = looks_like_file || exact_exists;
|
|
||||||
+ // Forced directory always wins; otherwise: exact > prefix > heuristic
|
|
||||||
+ let is_file = if forced_dir { false }
|
|
||||||
+ else if exact_exists { true }
|
|
||||||
+ else if prefix_exists { false }
|
|
||||||
+ else { looks_like_file };
|
|
||||||
|
|
||||||
if is_file {
|
|
||||||
Ok(PathQuery { value: trimmed.to_string(), is_prefix: false })
|
|
||||||
} else {
|
|
||||||
let escaped = escape_like(trimmed);
|
|
||||||
Ok(PathQuery { value: format!("{escaped}/%"), is_prefix: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
- let pq = build_path_query(conn, path)?;
|
|
||||||
+ let pq = build_path_query(conn, path, project_id)?;
|
|
||||||
|
|
||||||
|
|
||||||
Add test coverage for the multi-project misclassification case:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
#[test]
|
|
||||||
fn test_build_path_query_dotless_subdir_file_uses_db_probe() {
|
|
||||||
@@
|
|
||||||
- let pq = build_path_query(&conn, "src/Dockerfile").unwrap();
|
|
||||||
+ let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
|
|
||||||
@@
|
|
||||||
- let pq2 = build_path_query(&conn2, "src/Dockerfile").unwrap();
|
|
||||||
+ let pq2 = build_path_query(&conn2, "src/Dockerfile", None).unwrap();
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ #[test]
|
|
||||||
+ fn test_build_path_query_probe_is_project_scoped() {
|
|
||||||
+ // Path exists as a dotless file in project 1; project 2 should not
|
|
||||||
+ // treat it as an exact file unless it exists there too.
|
|
||||||
+ let conn = setup_test_db();
|
|
||||||
+ insert_project(&conn, 1, "team/a");
|
|
||||||
+ insert_project(&conn, 2, "team/b");
|
|
||||||
+ insert_mr(&conn, 1, 1, 10, "author_a", "opened");
|
|
||||||
+ insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
|
||||||
+ insert_diffnote(&conn, 1, 1, 1, "rev", "infra/Makefile", "note");
|
|
||||||
+
|
|
||||||
+ let pq_scoped = build_path_query(&conn, "infra/Makefile", Some(2)).unwrap();
|
|
||||||
+ assert!(pq_scoped.is_prefix); // should fall back to prefix in project 2
|
|
||||||
+ }
|
|
||||||
|
|
||||||
2) Bound robot payload sizes for participants and mr_refs (with totals + truncation)
|
|
||||||
Why
|
|
||||||
|
|
||||||
mr_refs and participants can become unbounded arrays in robot mode, which is a real operational hazard:
|
|
||||||
|
|
||||||
huge JSON → slow, noisy diffs, brittle downstream pipelines
|
|
||||||
|
|
||||||
potential SQLite group_concat truncation becomes invisible (and you can’t distinguish “no refs” vs “refs truncated”)
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
Introduce hard caps and explicit metadata:
|
|
||||||
|
|
||||||
participants_total, participants_truncated
|
|
||||||
|
|
||||||
mr_refs_total, mr_refs_truncated
|
|
||||||
|
|
||||||
This is not scope creep—it’s defensive output hygiene.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
pub struct ActiveDiscussion {
|
|
||||||
@@
|
|
||||||
pub participants: Vec<String>,
|
|
||||||
+ pub participants_total: u32,
|
|
||||||
+ pub participants_truncated: bool,
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
pub struct OverlapUser {
|
|
||||||
@@
|
|
||||||
pub mr_refs: Vec<String>,
|
|
||||||
+ pub mr_refs_total: u32,
|
|
||||||
+ pub mr_refs_truncated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Implementation sketch (Rust-side, deterministic):
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
fn query_active(...) -> Result<ActiveResult> {
|
|
||||||
+ const MAX_PARTICIPANTS: usize = 50;
|
|
||||||
@@
|
|
||||||
- participants.sort();
|
|
||||||
+ participants.sort();
|
|
||||||
+ let participants_total = participants.len() as u32;
|
|
||||||
+ let participants_truncated = participants.len() > MAX_PARTICIPANTS;
|
|
||||||
+ if participants_truncated {
|
|
||||||
+ participants.truncate(MAX_PARTICIPANTS);
|
|
||||||
+ }
|
|
||||||
@@
|
|
||||||
Ok(ActiveDiscussion {
|
|
||||||
@@
|
|
||||||
participants,
|
|
||||||
+ participants_total,
|
|
||||||
+ participants_truncated,
|
|
||||||
})
|
|
||||||
@@
|
|
||||||
fn query_overlap(...) -> Result<OverlapResult> {
|
|
||||||
+ const MAX_MR_REFS_PER_USER: usize = 50;
|
|
||||||
@@
|
|
||||||
.map(|a| {
|
|
||||||
let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
|
|
||||||
mr_refs.sort();
|
|
||||||
+ let mr_refs_total = mr_refs.len() as u32;
|
|
||||||
+ let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
|
|
||||||
+ if mr_refs_truncated {
|
|
||||||
+ mr_refs.truncate(MAX_MR_REFS_PER_USER);
|
|
||||||
+ }
|
|
||||||
OverlapUser {
|
|
||||||
@@
|
|
||||||
mr_refs,
|
|
||||||
+ mr_refs_total,
|
|
||||||
+ mr_refs_truncated,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
Update robot JSON accordingly:
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
fn active_to_json(r: &ActiveResult) -> serde_json::Value {
|
|
||||||
@@
|
|
||||||
"participants": d.participants,
|
|
||||||
+ "participants_total": d.participants_total,
|
|
||||||
+ "participants_truncated": d.participants_truncated,
|
|
||||||
}))
|
|
||||||
@@
|
|
||||||
fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
|
|
||||||
@@
|
|
||||||
"mr_refs": u.mr_refs,
|
|
||||||
+ "mr_refs_total": u.mr_refs_total,
|
|
||||||
+ "mr_refs_truncated": u.mr_refs_truncated,
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
Also update robot-docs manifest schema snippet for who.active.discussions[] and who.overlap.users[].
|
|
||||||
|
|
||||||
3) Add truncation metadata to Workload sections (same LIMIT+1 pattern)
|
|
||||||
Why
|
|
||||||
|
|
||||||
Workload is the mode most likely to be consumed by agents, and right now it has silent truncation (each section is LIMIT N with no signal). Your plan already treats truncation as a first-class contract elsewhere; Workload should match.
|
|
||||||
|
|
||||||
Revision
|
|
||||||
|
|
||||||
For each workload query:
|
|
||||||
|
|
||||||
request LIMIT + 1
|
|
||||||
|
|
||||||
set *_truncated booleans
|
|
||||||
|
|
||||||
trim to requested limit
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
pub struct WorkloadResult {
|
|
||||||
pub username: String,
|
|
||||||
pub assigned_issues: Vec<WorkloadIssue>,
|
|
||||||
pub authored_mrs: Vec<WorkloadMr>,
|
|
||||||
pub reviewing_mrs: Vec<WorkloadMr>,
|
|
||||||
pub unresolved_discussions: Vec<WorkloadDiscussion>,
|
|
||||||
+ pub assigned_issues_truncated: bool,
|
|
||||||
+ pub authored_mrs_truncated: bool,
|
|
||||||
+ pub reviewing_mrs_truncated: bool,
|
|
||||||
+ pub unresolved_discussions_truncated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
And in JSON include the booleans (plus you already have summary.counts).
|
|
||||||
|
|
||||||
This is mechanically repetitive but extremely valuable for automation.
|
|
||||||
|
|
||||||
4) Rename “Last Active” → “Last Seen” for Expert/Overlap
|
|
||||||
Why
|
|
||||||
|
|
||||||
For “author” rows, the timestamp is derived from review activity on their MR (via MAX(n.created_at)), not necessarily that person’s direct action. Calling that “active” is semantically misleading. “Last seen” is accurate across both reviewer+author branches.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
pub struct Expert {
|
|
||||||
@@
|
|
||||||
- pub last_active_ms: i64,
|
|
||||||
+ pub last_seen_ms: i64,
|
|
||||||
}
|
|
||||||
@@
|
|
||||||
pub struct OverlapUser {
|
|
||||||
@@
|
|
||||||
- pub last_touch_at: i64,
|
|
||||||
+ pub last_seen_at: i64,
|
|
||||||
@@
|
|
||||||
fn print_expert_human(...) {
|
|
||||||
@@
|
|
||||||
- style("Last Active").bold(),
|
|
||||||
+ style("Last Seen").bold(),
|
|
||||||
@@
|
|
||||||
- style(format_relative_time(expert.last_active_ms)).dim(),
|
|
||||||
+ style(format_relative_time(expert.last_seen_ms)).dim(),
|
|
||||||
|
|
||||||
|
|
||||||
(Keep internal SQL aliases consistent: last_seen_at everywhere.)
|
|
||||||
|
|
||||||
5) Make MR state filtering consistent in Expert/Overlap reviewer branches
|
|
||||||
Why
|
|
||||||
|
|
||||||
You already restrict Overlap author branch to opened|merged, but reviewer branches can include closed/unmerged noise. Consistency improves signal quality and can reduce scan churn.
|
|
||||||
|
|
||||||
Low-risk revision: apply the same state filter to reviewer branches (Expert + Overlap). You can keep “closed” excluded by default without adding new flags.
|
|
||||||
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.is_system = 0
|
|
||||||
@@
|
|
||||||
- AND n.created_at >= ?2
|
|
||||||
+ AND m.state IN ('opened','merged')
|
|
||||||
+ AND n.created_at >= ?2
|
|
||||||
|
|
||||||
|
|
||||||
This is a semantic choice; if you later want archaeology across closed/unmerged, that belongs in a separate mode/flag, but I would not add it now.
|
|
||||||
|
|
||||||
6) Add a design principle for bounded outputs (aligns with robot-first reproducibility)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
10. **Truncation transparency.** Result types carry a `truncated: bool` flag...
|
|
||||||
+11. **Bounded payloads.** Robot JSON must never emit unbounded arrays (participants, refs).
|
|
||||||
+ Large list fields are capped with `*_total` + `*_truncated` so agents can page/retry.
|
|
||||||
|
|
||||||
Consolidated plan metadata bump (Iteration 7)
|
|
||||||
diff
|
|
||||||
Copy code
|
|
||||||
diff --git a/who-command-design.md b/who-command-design.md
|
|
||||||
@@
|
|
||||||
-iteration: 6
|
|
||||||
+iteration: 7
|
|
||||||
updated: 2026-02-07
|
|
||||||
|
|
||||||
Net effect (what you get)
|
|
||||||
|
|
||||||
Correct path classification under -p scoping (no cross-project probe leakage)
|
|
||||||
|
|
||||||
Deterministic + bounded robot payloads (no giant JSON surprises)
|
|
||||||
|
|
||||||
Uniform truncation contract across all modes (Workload no longer silently truncates)
|
|
||||||
|
|
||||||
Clearer semantics (“Last Seen” avoids misinterpretation)
|
|
||||||
|
|
||||||
Cleaner signals (reviewer branches ignore closed/unmerged by default)
|
|
||||||
|
|
||||||
If you want, I can also produce a second diff that updates the robot-docs schema block and the Verification EXPLAIN expectations to reflect the new probe queries and the state filter.
|
|
||||||
21
migrations/022_notes_query_index.sql
Normal file
21
migrations/022_notes_query_index.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration 022: Composite query indexes for notes + author_id column
|
||||||
|
-- Optimizes author-scoped and project-scoped date-range queries on notes.
|
||||||
|
-- Adds discussion JOIN indexes and immutable author identity column.
|
||||||
|
|
||||||
|
-- Composite index for author-scoped queries (who command, notes --author)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_user_created
|
||||||
|
ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC)
|
||||||
|
WHERE is_system = 0;
|
||||||
|
|
||||||
|
-- Composite index for project-scoped date-range queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_project_created
|
||||||
|
ON notes(project_id, created_at DESC, id DESC)
|
||||||
|
WHERE is_system = 0;
|
||||||
|
|
||||||
|
-- Discussion JOIN indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);
|
||||||
|
|
||||||
|
-- Immutable author identity column (GitLab numeric user ID)
|
||||||
|
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;
|
||||||
5
migrations/023_issue_detail_fields.sql
Normal file
5
migrations/023_issue_detail_fields.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE issues ADD COLUMN closed_at TEXT;
|
||||||
|
ALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (23, strftime('%s', 'now') * 1000, 'Add closed_at and confidential to issues');
|
||||||
153
migrations/024_note_documents.sql
Normal file
153
migrations/024_note_documents.sql
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
-- Migration 024: Add 'note' source_type to documents and dirty_sources
|
||||||
|
-- SQLite does not support ALTER CONSTRAINT, so we use the table-rebuild pattern.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. Rebuild dirty_sources with updated CHECK constraint
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE dirty_sources_new (
|
||||||
|
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
queued_at INTEGER NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
next_attempt_at INTEGER,
|
||||||
|
PRIMARY KEY(source_type, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO dirty_sources_new SELECT * FROM dirty_sources;
|
||||||
|
DROP TABLE dirty_sources;
|
||||||
|
ALTER TABLE dirty_sources_new RENAME TO dirty_sources;
|
||||||
|
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. Rebuild documents with updated CHECK constraint
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 2a. Backup junction table data
|
||||||
|
CREATE TEMP TABLE _doc_labels_backup AS SELECT * FROM document_labels;
|
||||||
|
CREATE TEMP TABLE _doc_paths_backup AS SELECT * FROM document_paths;
|
||||||
|
|
||||||
|
-- 2b. Drop all triggers that reference documents
|
||||||
|
DROP TRIGGER IF EXISTS documents_ai;
|
||||||
|
DROP TRIGGER IF EXISTS documents_ad;
|
||||||
|
DROP TRIGGER IF EXISTS documents_au;
|
||||||
|
DROP TRIGGER IF EXISTS documents_embeddings_ad;
|
||||||
|
|
||||||
|
-- 2c. Drop junction tables (they have FK references to documents)
|
||||||
|
DROP TABLE IF EXISTS document_labels;
|
||||||
|
DROP TABLE IF EXISTS document_paths;
|
||||||
|
|
||||||
|
-- 2d. Create new documents table with 'note' in CHECK constraint
|
||||||
|
CREATE TABLE documents_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||||
|
author_username TEXT,
|
||||||
|
label_names TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER,
|
||||||
|
url TEXT,
|
||||||
|
title TEXT,
|
||||||
|
content_text TEXT NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
labels_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
paths_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
is_truncated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
truncated_reason TEXT CHECK (
|
||||||
|
truncated_reason IN (
|
||||||
|
'token_limit_middle_drop','single_note_oversized','first_last_oversized',
|
||||||
|
'hard_cap_oversized'
|
||||||
|
)
|
||||||
|
OR truncated_reason IS NULL
|
||||||
|
),
|
||||||
|
UNIQUE(source_type, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2e. Copy all existing data
|
||||||
|
INSERT INTO documents_new SELECT * FROM documents;
|
||||||
|
|
||||||
|
-- 2f. Swap tables
|
||||||
|
DROP TABLE documents;
|
||||||
|
ALTER TABLE documents_new RENAME TO documents;
|
||||||
|
|
||||||
|
-- 2g. Recreate all indexes on documents
|
||||||
|
CREATE INDEX idx_documents_project_updated ON documents(project_id, updated_at);
|
||||||
|
CREATE INDEX idx_documents_author ON documents(author_username);
|
||||||
|
CREATE INDEX idx_documents_source ON documents(source_type, source_id);
|
||||||
|
CREATE INDEX idx_documents_hash ON documents(content_hash);
|
||||||
|
|
||||||
|
-- 2h. Recreate junction tables
|
||||||
|
CREATE TABLE document_labels (
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
label_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(document_id, label_name)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX idx_document_labels_label ON document_labels(label_name);
|
||||||
|
|
||||||
|
CREATE TABLE document_paths (
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(document_id, path)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX idx_document_paths_path ON document_paths(path);
|
||||||
|
|
||||||
|
-- 2i. Restore junction table data from backups
|
||||||
|
INSERT INTO document_labels SELECT * FROM _doc_labels_backup;
|
||||||
|
INSERT INTO document_paths SELECT * FROM _doc_paths_backup;
|
||||||
|
|
||||||
|
-- 2j. Recreate FTS triggers (from migration 008)
|
||||||
|
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||||
|
INSERT INTO documents_fts(rowid, title, content_text)
|
||||||
|
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||||
|
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||||
|
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_au AFTER UPDATE ON documents
|
||||||
|
WHEN old.title IS NOT new.title OR old.content_text != new.content_text
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||||
|
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||||
|
INSERT INTO documents_fts(rowid, title, content_text)
|
||||||
|
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 2k. Recreate embeddings cleanup trigger (from migration 009)
|
||||||
|
CREATE TRIGGER documents_embeddings_ad AFTER DELETE ON documents BEGIN
|
||||||
|
DELETE FROM embeddings
|
||||||
|
WHERE rowid >= old.id * 1000
|
||||||
|
AND rowid < (old.id + 1) * 1000;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 2l. Rebuild FTS index to ensure consistency after table swap
|
||||||
|
INSERT INTO documents_fts(documents_fts) VALUES('rebuild');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. Defense triggers: clean up documents when notes are
|
||||||
|
-- deleted or flipped to system notes
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER notes_ad_cleanup AFTER DELETE ON notes
|
||||||
|
WHEN old.is_system = 0
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM documents WHERE source_type = 'note' AND source_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER notes_au_system_cleanup AFTER UPDATE OF is_system ON notes
|
||||||
|
WHEN NEW.is_system = 1 AND OLD.is_system = 0
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM documents WHERE source_type = 'note' AND source_id = OLD.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. Drop temp backup tables
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS _doc_labels_backup;
|
||||||
|
DROP TABLE IF EXISTS _doc_paths_backup;
|
||||||
8
migrations/025_note_dirty_backfill.sql
Normal file
8
migrations/025_note_dirty_backfill.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Backfill existing non-system notes into dirty queue for document generation.
|
||||||
|
-- Only seeds notes that don't already have documents and aren't already queued.
|
||||||
|
INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||||
|
SELECT 'note', n.id, CAST(strftime('%s', 'now') AS INTEGER) * 1000
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN documents d ON d.source_type = 'note' AND d.source_id = n.id
|
||||||
|
WHERE n.is_system = 0 AND d.id IS NULL
|
||||||
|
ON CONFLICT(source_type, source_id) DO NOTHING;
|
||||||
20
migrations/026_scoring_indexes.sql
Normal file
20
migrations/026_scoring_indexes.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Indexes for time-decay expert scoring: dual-path matching and reviewer participation.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_author
|
||||||
|
ON notes(position_old_path, author_username, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr
|
||||||
|
ON mr_file_changes(old_path, project_id, merge_request_id)
|
||||||
|
WHERE old_path IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
|
||||||
|
ON mr_file_changes(new_path, project_id, merge_request_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
||||||
|
ON notes(discussion_id, author_username, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
|
ON notes(position_old_path, project_id, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
250
plans/plan-to-beads-v2-draft.md
Normal file
250
plans/plan-to-beads-v2-draft.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# plan-to-beads v2 — Draft for Review
|
||||||
|
|
||||||
|
This is a draft of the improved skill. Review before applying to `~/.claude/skills/plan-to-beads/SKILL.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: plan-to-beads
|
||||||
|
description: Transforms markdown implementation plans into granular, agent-ready beads with dependency graphs. Each bead is fully self-contained — an agent can execute it with zero external context. Triggers on "break down this plan", "create beads from", "convert to beads", "make issues from plan".
|
||||||
|
argument-hint: "[path/to/plan.md]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan to Beads Conversion
|
||||||
|
|
||||||
|
## The Prime Directive
|
||||||
|
|
||||||
|
**Every bead must be executable by an agent that has ONLY the bead description.** No plan document. No Slack context. No "see the PRD." The bead IS the spec. If an agent can't start coding within 60 seconds of reading the bead, it's not ready.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ 1. PARSE │──▶│ 2. MINE │──▶│ 3. BUILD │──▶│ 4. LINK │──▶│ 5. AUDIT │
|
||||||
|
│ Structure│ │ Context │ │ Beads │ │ Deps │ │ Quality │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Parse Structure
|
||||||
|
|
||||||
|
Read the plan document. Identify:
|
||||||
|
- **Epics**: Major sections / phases / milestones
|
||||||
|
- **Tasks**: Implementable units with clear outcomes (1-4 hour scope)
|
||||||
|
- **Subtasks**: Granular steps within tasks
|
||||||
|
|
||||||
|
### 2. Mine Context
|
||||||
|
|
||||||
|
This is the critical step. For EACH identified task, extract everything an implementing agent will need.
|
||||||
|
|
||||||
|
#### From the plan document:
|
||||||
|
|
||||||
|
| Extract | Where to look | Example |
|
||||||
|
|---------|--------------|---------|
|
||||||
|
| **Rationale** | Intro paragraphs, "why" sections | "We need this because the current approach causes N+1 queries" |
|
||||||
|
| **Approach details** | Implementation notes, code snippets, architecture decisions | "Use a 5-stage pipeline: SEED → HYDRATE → ..." |
|
||||||
|
| **Test requirements** | TDD sections, acceptance criteria, "verify by" notes | "Test that empty input returns empty vec" |
|
||||||
|
| **Edge cases & risks** | Warnings, gotchas, "watch out for" notes | "Multi-byte UTF-8 chars can cause panics at byte boundaries" |
|
||||||
|
| **Data shapes** | Type definitions, struct descriptions, API contracts | "TimelineEvent { kind: EventKind, timestamp: DateTime, ... }" |
|
||||||
|
| **File paths** | Explicit mentions or inferable from module structure | "src/core/timeline_seed.rs" |
|
||||||
|
| **Dependencies on other tasks** | "requires X", "after Y is done", "uses Z from step N" | "Consumes the TimelineEvent struct from the types task" |
|
||||||
|
| **Verification commands** | Test commands, CLI invocations, expected outputs | "cargo test timeline_seed -- --nocapture" |
|
||||||
|
|
||||||
|
#### From the codebase:
|
||||||
|
|
||||||
|
Search the codebase to supplement what the plan says:
|
||||||
|
- Find existing files mentioned or implied by the plan
|
||||||
|
- Discover patterns the task should follow (e.g., how existing similar modules are structured)
|
||||||
|
- Check test files for naming conventions and test infrastructure in use
|
||||||
|
- Confirm exact file paths rather than guessing
|
||||||
|
|
||||||
|
Use codebase search tools (WarpGrep, Explore agent, or targeted Grep/Glob) appropriate to the scope of what you need to find.
|
||||||
|
|
||||||
|
### 3. Build Beads
|
||||||
|
|
||||||
|
Use `br` exclusively.
|
||||||
|
|
||||||
|
| Type | Priority | Command |
|
||||||
|
|------|----------|---------|
|
||||||
|
| Epic | 1 | `br create "Epic: [Title]" -p 1` |
|
||||||
|
| Task | 2-3 | `br create "[Verb] [Object]" -p 2` |
|
||||||
|
| Subtask | 3-4 | `br q "[Verb] [Object]"` |
|
||||||
|
|
||||||
|
**Granularity target**: Each bead completable in 1-4 hours by one agent.
|
||||||
|
|
||||||
|
#### Description Templates
|
||||||
|
|
||||||
|
Use the **full template** for all task-level beads. Use the **light template** only for trivially small tasks (config change, single-line fix, add a re-export).
|
||||||
|
|
||||||
|
##### Full Template (default)
|
||||||
|
|
||||||
|
```
|
||||||
|
## Background
|
||||||
|
[WHY this exists. What problem it solves. How it fits into the larger system.
|
||||||
|
Include enough context that an agent unfamiliar with the project understands
|
||||||
|
the purpose. Reference architectural patterns in use.]
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
[HOW to implement. Be specific:
|
||||||
|
- Data structures / types to create or use (include field names and types)
|
||||||
|
- Algorithms or patterns to follow
|
||||||
|
- Code snippets from the plan if available
|
||||||
|
- Which existing code to reference for patterns (exact file paths)]
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Specified (from plan — implement as-is)
|
||||||
|
- [ ] <criteria explicitly stated in the plan>
|
||||||
|
- [ ] <criteria explicitly stated in the plan>
|
||||||
|
|
||||||
|
### Proposed (inferred — confirm with user before implementing) [?]
|
||||||
|
- [ ] [?] <criteria the agent inferred but the plan didn't specify>
|
||||||
|
- [ ] [?] <criteria the agent inferred but the plan didn't specify>
|
||||||
|
|
||||||
|
**ASSUMPTION RULE**: If proposed criteria exceed ~30% of total, STOP.
|
||||||
|
The bead needs human input before it's ready for implementation. Flag it
|
||||||
|
in the audit output and ask the user to refine the ACs.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
[Exact paths to create or modify. Confirmed by searching the codebase.]
|
||||||
|
- CREATE: src/foo/bar.rs
|
||||||
|
- MODIFY: src/foo/mod.rs (add pub mod bar)
|
||||||
|
- MODIFY: tests/foo_tests.rs (add test module)
|
||||||
|
|
||||||
|
## TDD Anchor
|
||||||
|
[The first test to write. This grounds the agent's work.]
|
||||||
|
RED: Write `test_<name>` in `<test_file>` that asserts <specific behavior>.
|
||||||
|
GREEN: Implement the minimal code to make it pass.
|
||||||
|
VERIFY: <project's test command> <pattern>
|
||||||
|
|
||||||
|
[If the plan specifies additional tests, list them all:]
|
||||||
|
- test_empty_input_returns_empty_vec
|
||||||
|
- test_single_issue_produces_one_event
|
||||||
|
- test_handles_missing_fields_gracefully
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
[Gotchas, risks, things that aren't obvious. Pulled from the plan's warnings,
|
||||||
|
known issues, or your analysis of the approach.]
|
||||||
|
- <edge case 1>
|
||||||
|
- <edge case 2>
|
||||||
|
|
||||||
|
## Dependency Context
|
||||||
|
[For each dependency, explain WHAT it provides that this bead consumes.
|
||||||
|
Not just "depends on bd-xyz" but "uses the `TimelineEvent` struct and
|
||||||
|
`SeedConfig` type defined in bd-xyz".]
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Light Template (trivially small tasks only)
|
||||||
|
|
||||||
|
Use this ONLY when the task is a one-liner or pure mechanical change (add a re-export, flip a config flag, rename a constant). If there's any ambiguity about approach, use the full template.
|
||||||
|
|
||||||
|
```
|
||||||
|
## What
|
||||||
|
[One sentence: what to do and where.]
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] <single binary criterion>
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- MODIFY: <exact path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Link Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
br dep add [blocker-id] [blocked-id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependency patterns:
|
||||||
|
- Types/structs → code that uses them
|
||||||
|
- Infrastructure (DB, config) → features that need them
|
||||||
|
- Core logic → extensions/enhancements
|
||||||
|
- Tests may depend on test helpers
|
||||||
|
|
||||||
|
**Critical**: When linking deps, update the "Dependency Context" section in the blocked bead to describe exactly what it receives from the blocker.
|
||||||
|
|
||||||
|
### 5. Audit Quality
|
||||||
|
|
||||||
|
Before reporting, review EVERY bead against this checklist:
|
||||||
|
|
||||||
|
| Check | Pass criteria |
|
||||||
|
|-------|--------------|
|
||||||
|
| **Self-contained?** | Agent can start coding in 60 seconds with ONLY this description |
|
||||||
|
| **TDD anchor?** | First test to write is named and described |
|
||||||
|
| **Binary criteria?** | Every acceptance criterion is pass/fail, not subjective |
|
||||||
|
| **Exact paths?** | File paths verified against codebase, not guessed |
|
||||||
|
| **Edge cases?** | At least 1 non-obvious gotcha identified |
|
||||||
|
| **Dep context?** | Each dependency explains WHAT it provides, not just its ID |
|
||||||
|
| **Approach specifics?** | Types, field names, patterns — not "implement the thing" |
|
||||||
|
| **Assumption budget?** | Proposed [?] criteria are <30% of total ACs |
|
||||||
|
|
||||||
|
If a bead fails any check, fix it before moving on. If the assumption budget is exceeded, flag the bead for human review rather than inventing more ACs.
|
||||||
|
|
||||||
|
## Assumption & AC Guidance
|
||||||
|
|
||||||
|
Agents filling in beads will inevitably encounter gaps in the plan. The rules:
|
||||||
|
|
||||||
|
1. **Never silently fill gaps.** If the plan doesn't specify a behavior, don't assume one and bury it in the ACs. Mark it `[?]` so the implementing agent knows to ask.
|
||||||
|
|
||||||
|
2. **Specify provenance on every AC.** Specified = from the plan. Proposed = your inference. The implementing agent treats these differently:
|
||||||
|
- **Specified**: implement without question
|
||||||
|
- **Proposed [?]**: pause and confirm with the user before implementing
|
||||||
|
|
||||||
|
3. **The 30% rule.** If more than ~30% of ACs on a bead are proposed/inferred, the plan was too vague for this task. Don't create the bead as-is. Instead:
|
||||||
|
- Create it with status noting "needs AC refinement"
|
||||||
|
- List the open questions explicitly
|
||||||
|
- Flag it in the output report under "Beads Needing Human Input"
|
||||||
|
|
||||||
|
4. **Prefer smaller scope over more assumptions.** If you're unsure whether a task should handle edge case X, make the bead's scope explicitly exclude it and note it as a potential follow-up. A bead that does less but does it right beats one that guesses wrong.
|
||||||
|
|
||||||
|
5. **Implementing agents: honor the markers.** When you encounter `[?]` on an AC, you MUST ask the user before implementing that behavior. Do not silently resolve it in either direction.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
After completion, report:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Beads Created: N total (X epics, Y tasks, Z subtasks)
|
||||||
|
|
||||||
|
### Quality Audit
|
||||||
|
- Beads scoring 4+: N/N (target: 100%)
|
||||||
|
- [list any beads that needed extra attention and why]
|
||||||
|
|
||||||
|
### Beads Needing Human Input
|
||||||
|
[List any beads where proposed ACs exceeded 30%, or where significant
|
||||||
|
ambiguity in the plan made self-contained descriptions impossible.
|
||||||
|
Include the specific open questions for each.]
|
||||||
|
|
||||||
|
### Critical Path
|
||||||
|
[blocker] → [blocked] → [blocked]
|
||||||
|
|
||||||
|
### Ready to Start
|
||||||
|
- bd-xxx: [Title] — [one-line summary of what agent will do]
|
||||||
|
- bd-yyy: [Title] — [one-line summary of what agent will do]
|
||||||
|
|
||||||
|
### Dependency Graph
|
||||||
|
[Brief visualization or description of the dep structure]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risk Tiers
|
||||||
|
|
||||||
|
| Operation | Tier | Behavior |
|
||||||
|
|-----------|------|----------|
|
||||||
|
| `br create` | SAFE | Auto-proceed |
|
||||||
|
| `br dep add` | SAFE | Auto-proceed |
|
||||||
|
| `br update --description` | CAUTION | Verify content |
|
||||||
|
| Bulk creation (>20 beads) | CAUTION | Confirm count first |
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why it's bad | Fix |
|
||||||
|
|-------------|-------------|-----|
|
||||||
|
| "Implement the pipeline stage" | Agent doesn't know WHAT to implement | Name the types, the function signatures, the test |
|
||||||
|
| "See plan for details" | Plan isn't available to the agent | Copy the relevant details INTO the bead |
|
||||||
|
| "Files: probably src/foo/" | Agent wastes time finding the right file | Search the codebase, confirm exact paths |
|
||||||
|
| "Should work correctly" | Not binary, not testable | "test_x passes" or "output matches Y" |
|
||||||
|
| No TDD anchor | Agent doesn't know where to start | Always specify the first test to write |
|
||||||
|
| "Depends on bd-xyz" (without context) | Agent doesn't know what bd-xyz provides | "Uses FooStruct and bar() function from bd-xyz" |
|
||||||
|
| Single-line description | Score 1 bead, agent is stuck | Use the full template, every section |
|
||||||
|
| Silently invented ACs | User surprised by implementation choices | Mark inferred ACs with [?], honor the 30% rule |
|
||||||
|
```
|
||||||
134
plans/time-decay-expert-scoring.feedback-6.md
Normal file
134
plans/time-decay-expert-scoring.feedback-6.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
I avoided everything already listed in your `Rejected Ideas` section and focused on net-new upgrades.
|
||||||
|
|
||||||
|
1. Centralize MR temporal semantics in one `mr_activity` CTE (architecture + correctness)
|
||||||
|
Why this improves the plan: right now the state-aware timestamp logic is repeated across multiple signal branches, while `closed_mr_multiplier` is applied later in Rust by string state checks. That split is brittle. A single `mr_activity` CTE removes drift risk, simplifies query maintenance, and avoids per-row state-string handling in Rust.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ SQL Restructure
|
||||||
|
+mr_activity AS (
|
||||||
|
+ SELECT
|
||||||
|
+ m.id AS mr_id,
|
||||||
|
+ m.project_id,
|
||||||
|
+ m.author_username,
|
||||||
|
+ m.state,
|
||||||
|
+ CASE
|
||||||
|
+ WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
||||||
|
+ WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
||||||
|
+ ELSE COALESCE(m.updated_at, m.created_at)
|
||||||
|
+ END AS activity_ts,
|
||||||
|
+ CASE
|
||||||
|
+ WHEN m.state = 'closed' THEN ?5
|
||||||
|
+ ELSE 1.0
|
||||||
|
+ END AS state_mult
|
||||||
|
+ FROM merge_requests m
|
||||||
|
+ WHERE m.state IN ('opened','merged','closed')
|
||||||
|
+),
|
||||||
|
@@
|
||||||
|
-... {state_aware_ts} AS seen_at, m.state AS mr_state
|
||||||
|
+... a.activity_ts AS seen_at, a.state_mult
|
||||||
|
@@
|
||||||
|
-SELECT username, signal, mr_id, qty, ts, mr_state FROM aggregated
|
||||||
|
+SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Parameterize `reviewer_min_note_chars` and tighten config validation (robustness)
|
||||||
|
Why this improves the plan: inlining `reviewer_min_note_chars` into SQL text creates statement-cache churn and avoidable SQL-text variability. Also, current validation misses finite-range guards (`NaN`, absurd half-lives). Parameterization + stronger validation reduces weird failure modes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 1. ScoringConfig (config.rs)
|
||||||
|
- reviewer_min_note_chars must be >= 0
|
||||||
|
+ reviewer_min_note_chars must be <= 4096
|
||||||
|
+ all half-life values must be <= 3650 (10 years safety cap)
|
||||||
|
+ closed_mr_multiplier must be finite and in (0.0, 1.0]
|
||||||
|
@@ SQL Restructure
|
||||||
|
-AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= {reviewer_min_note_chars}
|
||||||
|
+AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add path canonicalization before probes/scoring (correctness + UX)
|
||||||
|
Why this improves the plan: rename-awareness helps only after path resolution succeeds. Inputs like `./src//foo.rs` or inconsistent trailing slashes can still miss. Canonicalizing query paths up front reduces false negatives and ambiguous suffix behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 3a. Path Resolution Probes (who.rs)
|
||||||
|
+Add `normalize_query_path()` before `build_path_query()`:
|
||||||
|
+- strip leading `./`
|
||||||
|
+- collapse repeated `/`
|
||||||
|
+- trim whitespace
|
||||||
|
+- preserve trailing `/` only for explicit prefix intent
|
||||||
|
+Expose both `path_input_original` and `path_input_normalized` in `resolved_input`.
|
||||||
|
@@ New Tests
|
||||||
|
+test_path_normalization_handles_dot_and_double_slash
|
||||||
|
+test_path_normalization_preserves_explicit_prefix_semantics
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add epsilon-based tie buckets for stable ranking (determinism)
|
||||||
|
Why this improves the plan: even with deterministic summation order, tiny `powf` platform differences can reorder near-equal scores. Tie bucketing keeps ordering stable and user-meaningful.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 4. Rust-Side Aggregation (who.rs)
|
||||||
|
-Sort on raw `f64` score — `(raw_score DESC, last_seen DESC, username ASC)`.
|
||||||
|
+Sort using a tie bucket:
|
||||||
|
+`score_bucket = (raw_score / 1e-9).floor() as i64`
|
||||||
|
+Order by `(score_bucket DESC, raw_score DESC, last_seen DESC, username ASC)`.
|
||||||
|
+This preserves precision while preventing meaningless micro-delta reorderings.
|
||||||
|
@@ New Tests
|
||||||
|
+test_near_equal_scores_use_stable_tie_bucket_order
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Add `--diagnose-score` aggregated diagnostics (operability)
|
||||||
|
Why this improves the plan: `--explain-score` tells “why this user scored”, but not “why this query behaved oddly” (path ambiguity, dedup collapse, old_path contribution share, filtered bots, window exclusions). Lightweight aggregate diagnostics are high-value without per-MR drill-down complexity.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ CLI changes (who.rs)
|
||||||
|
+Add `--diagnose-score` flag (compatible with `--explain-score`, incompatible with `--detail`).
|
||||||
|
+When enabled, include:
|
||||||
|
+- matched_notes_raw_count
|
||||||
|
+- matched_notes_dedup_count
|
||||||
|
+- matched_file_changes_raw_count
|
||||||
|
+- matched_file_changes_dedup_count
|
||||||
|
+- rows_excluded_by_window_upper_bound
|
||||||
|
+- users_filtered_by_excluded_usernames
|
||||||
|
+- query_elapsed_ms
|
||||||
|
@@ Robot output
|
||||||
|
+`diagnostics` object emitted only when `--diagnose-score` is set.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Add probe-optimized indexes for path resolution (performance)
|
||||||
|
Why this improves the plan: current proposed indexes are optimized for scoring joins, but `build_path_query()` and `suffix_probe()` run existence/path-only probes where `author_username` is not constrained. Dedicated probe indexes will materially reduce latency for path lookup modes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 6. Index Migration (db.rs)
|
||||||
|
+-- Fast exact/prefix/suffix path probes on notes (no author predicate)
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_new_path_project_created
|
||||||
|
+ ON notes(position_new_path, project_id, created_at)
|
||||||
|
+ WHERE note_type = 'DiffNote' AND is_system = 0 AND position_new_path IS NOT NULL;
|
||||||
|
+
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
|
+ ON notes(position_old_path, project_id, created_at)
|
||||||
|
+ WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Add multi-path expert scoring (`--path` repeatable) with dedup across paths (feature + utility)
|
||||||
|
Why this improves the plan: current model is single-path centric. Real ownership questions are usually subsystem-level. Repeatable paths/prefixes let users ask “who knows auth stack?” in one call. Dedup by `(username, signal, mr_id)` avoids double-counting same MR touching multiple requested paths.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ CLI/feature scope
|
||||||
|
+Add repeatable `--path` in expert mode:
|
||||||
|
+`lore who --expert --path src/auth/ --path src/session/`
|
||||||
|
+Optional `--path-file <file>` for large path sets (one per line).
|
||||||
|
@@ SQL Restructure
|
||||||
|
+Add `requested_paths` CTE and match each source against that set.
|
||||||
|
+Ensure dedup key includes `(username, signal, mr_id)` so one MR contributes once per signal even if multiple paths match.
|
||||||
|
@@ New Tests
|
||||||
|
+test_multi_path_query_unions_results_without_double_counting
|
||||||
|
+test_multi_path_with_overlap_prefixes_is_idempotent
|
||||||
|
```
|
||||||
|
|
||||||
|
These 7 revisions keep your current model direction intact, but reduce correctness drift risk, harden edge handling, improve query observability, and make the feature materially more useful for real ownership workflows.
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
plan: true
|
plan: true
|
||||||
title: ""
|
title: ""
|
||||||
status: iterating
|
status: iterating
|
||||||
iteration: 5
|
iteration: 6
|
||||||
target_iterations: 8
|
target_iterations: 8
|
||||||
beads_revision: 1
|
beads_revision: 2
|
||||||
related_plans: []
|
related_plans: []
|
||||||
created: 2026-02-08
|
created: 2026-02-08
|
||||||
updated: 2026-02-09
|
updated: 2026-02-12
|
||||||
---
|
---
|
||||||
|
|
||||||
# Time-Decay Expert Scoring Model
|
# Time-Decay Expert Scoring Model
|
||||||
@@ -70,7 +70,8 @@ Author/reviewer signals are deduplicated per MR (one signal per distinct MR). No
|
|||||||
1. **`src/core/config.rs`** — Add half-life fields + assigned-only reviewer config to `ScoringConfig`; add config validation
|
1. **`src/core/config.rs`** — Add half-life fields + assigned-only reviewer config to `ScoringConfig`; add config validation
|
||||||
2. **`src/cli/commands/who.rs`** — Core changes:
|
2. **`src/cli/commands/who.rs`** — Core changes:
|
||||||
- Add `half_life_decay()` pure function
|
- Add `half_life_decay()` pure function
|
||||||
- Restructure `query_expert()`: SQL returns hybrid-aggregated signal rows with timestamps (MR-level for author/reviewer, note-count-per-MR for notes), Rust applies decay + `log2(1+count)` + final ranking
|
- Add `normalize_query_path()` for input canonicalization before path resolution
|
||||||
|
- Restructure `query_expert()`: SQL returns hybrid-aggregated signal rows with timestamps and state multiplier (MR-level for author/reviewer, note-count-per-MR for notes), Rust applies decay + `log2(1+count)` + final ranking
|
||||||
- Match both `new_path` and `old_path` in all signal queries (rename awareness)
|
- Match both `new_path` and `old_path` in all signal queries (rename awareness)
|
||||||
- Extend rename awareness to `build_path_query()` probes and `suffix_probe()` (not just scoring)
|
- Extend rename awareness to `build_path_query()` probes and `suffix_probe()` (not just scoring)
|
||||||
- Split reviewer signal into participated vs assigned-only
|
- Split reviewer signal into participated vs assigned-only
|
||||||
@@ -106,10 +107,10 @@ pub struct ScoringConfig {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Config validation**: Add a `validate_scoring()` call in `Config::load_from_path()` after deserialization:
|
**Config validation**: Add a `validate_scoring()` call in `Config::load_from_path()` after deserialization:
|
||||||
- All `*_half_life_days` must be > 0 (prevents division by zero in decay function)
|
- All `*_half_life_days` must be > 0 and <= 3650 (prevents division by zero in decay function; rejects absurd 10+ year half-lives that would effectively disable decay)
|
||||||
- All `*_weight` / `*_bonus` must be >= 0 (negative weights produce nonsensical scores)
|
- All `*_weight` / `*_bonus` must be >= 0 (negative weights produce nonsensical scores)
|
||||||
- `closed_mr_multiplier` must be in `(0.0, 1.0]` (0 would discard closed MRs entirely; >1 would over-weight them)
|
- `closed_mr_multiplier` must be finite (not NaN/Inf) and in `(0.0, 1.0]` (0 would discard closed MRs entirely; >1 would over-weight them; NaN/Inf would propagate through all scores)
|
||||||
- `reviewer_min_note_chars` must be >= 0 (0 disables the filter; typical useful values: 10-50)
|
- `reviewer_min_note_chars` must be >= 0 and <= 4096 (0 disables the filter; 4096 is a sane upper bound — no real review comment needs to be longer to qualify; typical useful values: 10-50)
|
||||||
- `excluded_usernames` entries must be non-empty strings (no blank entries)
|
- `excluded_usernames` entries must be non-empty strings (no blank entries)
|
||||||
- Return `LoreError::ConfigInvalid` with a clear message on failure
|
- Return `LoreError::ConfigInvalid` with a clear message on failure
|
||||||
|
|
||||||
@@ -126,9 +127,9 @@ fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
|
|||||||
|
|
||||||
### 3. SQL Restructure (who.rs)
|
### 3. SQL Restructure (who.rs)
|
||||||
|
|
||||||
The SQL uses **CTE-based dual-path matching** and **hybrid aggregation**. Rather than repeating `OR old_path` in every signal subquery, two foundational CTEs (`matched_notes`, `matched_file_changes`) centralize path matching. A third CTE (`reviewer_participation`) precomputes which reviewers actually left DiffNotes, avoiding correlated `EXISTS`/`NOT EXISTS` subqueries.
|
The SQL uses **CTE-based dual-path matching**, a **centralized `mr_activity` CTE**, and **hybrid aggregation**. Rather than repeating `OR old_path` in every signal subquery, two foundational CTEs (`matched_notes`, `matched_file_changes`) centralize path matching. A `mr_activity` CTE centralizes the state-aware timestamp and state multiplier in one place, eliminating repetition of the CASE expression across signals 3, 4a, 4b. A fourth CTE (`reviewer_participation`) precomputes which reviewers actually left DiffNotes, avoiding correlated `EXISTS`/`NOT EXISTS` subqueries.
|
||||||
|
|
||||||
MR-level signals return one row per (username, signal, mr_id) with a timestamp; note signals return one row per (username, mr_id) with `note_count` and `max_ts`. This keeps row counts bounded (dozens to low hundreds per path) while giving Rust the data it needs for decay and `log2(1+count)`.
|
MR-level signals return one row per (username, signal, mr_id) with a timestamp and state multiplier; note signals return one row per (username, mr_id) with `note_count` and `max_ts`. This keeps row counts bounded (dozens to low hundreds per path) while giving Rust the data it needs for decay and `log2(1+count)`.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
WITH matched_notes_raw AS (
|
WITH matched_notes_raw AS (
|
||||||
@@ -177,6 +178,24 @@ matched_file_changes AS (
|
|||||||
SELECT DISTINCT merge_request_id, project_id
|
SELECT DISTINCT merge_request_id, project_id
|
||||||
FROM matched_file_changes_raw
|
FROM matched_file_changes_raw
|
||||||
),
|
),
|
||||||
|
mr_activity AS (
|
||||||
|
-- Centralized state-aware timestamps and state multiplier.
|
||||||
|
-- Defined once, referenced by all file-change-based signals (3, 4a, 4b).
|
||||||
|
-- Scoped to MRs matched by file changes to avoid materializing the full MR table.
|
||||||
|
SELECT DISTINCT
|
||||||
|
m.id AS mr_id,
|
||||||
|
m.author_username,
|
||||||
|
m.state,
|
||||||
|
CASE
|
||||||
|
WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
||||||
|
WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
||||||
|
ELSE COALESCE(m.updated_at, m.created_at)
|
||||||
|
END AS activity_ts,
|
||||||
|
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||||
|
FROM merge_requests m
|
||||||
|
JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id
|
||||||
|
WHERE m.state IN ('opened','merged','closed')
|
||||||
|
),
|
||||||
reviewer_participation AS (
|
reviewer_participation AS (
|
||||||
-- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
|
-- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
|
||||||
-- Materialized once, then joined against mr_reviewers to classify.
|
-- Materialized once, then joined against mr_reviewers to classify.
|
||||||
@@ -185,17 +204,20 @@ reviewer_participation AS (
|
|||||||
-- reviewer from 3-point to 10-point weight, defeating the purpose of the split.
|
-- reviewer from 3-point to 10-point weight, defeating the purpose of the split.
|
||||||
-- Note: mn.id refers back to notes.id, so we join notes to access the body column
|
-- Note: mn.id refers back to notes.id, so we join notes to access the body column
|
||||||
-- (not carried in matched_notes to avoid bloating that CTE with body text).
|
-- (not carried in matched_notes to avoid bloating that CTE with body text).
|
||||||
|
-- ?6 is the configured reviewer_min_note_chars value (default 20).
|
||||||
SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username
|
SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username
|
||||||
FROM matched_notes mn
|
FROM matched_notes mn
|
||||||
JOIN discussions d ON mn.discussion_id = d.id
|
JOIN discussions d ON mn.discussion_id = d.id
|
||||||
JOIN notes n_body ON mn.id = n_body.id
|
JOIN notes n_body ON mn.id = n_body.id
|
||||||
WHERE d.merge_request_id IS NOT NULL
|
WHERE d.merge_request_id IS NOT NULL
|
||||||
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= {reviewer_min_note_chars}
|
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
|
||||||
),
|
),
|
||||||
raw AS (
|
raw AS (
|
||||||
-- Signal 1: DiffNote reviewer (individual notes for note_cnt)
|
-- Signal 1: DiffNote reviewer (individual notes for note_cnt)
|
||||||
|
-- Computes state_mult inline (not via mr_activity) because this joins through discussions, not file changes.
|
||||||
SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal,
|
SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal,
|
||||||
m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at, m.state AS mr_state
|
m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at,
|
||||||
|
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||||
FROM matched_notes mn
|
FROM matched_notes mn
|
||||||
JOIN discussions d ON mn.discussion_id = d.id
|
JOIN discussions d ON mn.discussion_id = d.id
|
||||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
@@ -205,8 +227,10 @@ raw AS (
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 2: DiffNote MR author
|
-- Signal 2: DiffNote MR author
|
||||||
|
-- Computes state_mult inline (same reason as signal 1).
|
||||||
SELECT m.author_username AS username, 'diffnote_author' AS signal,
|
SELECT m.author_username AS username, 'diffnote_author' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at, m.state AS mr_state
|
m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at,
|
||||||
|
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||||
FROM merge_requests m
|
FROM merge_requests m
|
||||||
JOIN discussions d ON d.merge_request_id = m.id
|
JOIN discussions d ON d.merge_request_id = m.id
|
||||||
JOIN matched_notes mn ON mn.discussion_id = d.id
|
JOIN matched_notes mn ON mn.discussion_id = d.id
|
||||||
@@ -216,65 +240,59 @@ raw AS (
|
|||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 3: MR author via file changes (state-aware timestamp)
|
-- Signal 3: MR author via file changes (uses mr_activity CTE for timestamp + state_mult)
|
||||||
SELECT m.author_username AS username, 'file_author' AS signal,
|
SELECT a.author_username AS username, 'file_author' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id,
|
a.mr_id, NULL AS note_id,
|
||||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
a.activity_ts AS seen_at, a.state_mult
|
||||||
FROM matched_file_changes mfc
|
FROM mr_activity a
|
||||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
WHERE a.author_username IS NOT NULL
|
||||||
WHERE m.author_username IS NOT NULL
|
AND a.activity_ts >= ?2
|
||||||
AND m.state IN ('opened','merged','closed')
|
AND a.activity_ts < ?4
|
||||||
AND {state_aware_ts} >= ?2
|
|
||||||
AND {state_aware_ts} < ?4
|
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
|
-- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
|
||||||
SELECT r.username AS username, 'file_reviewer_participated' AS signal,
|
SELECT r.username AS username, 'file_reviewer_participated' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id,
|
a.mr_id, NULL AS note_id,
|
||||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
a.activity_ts AS seen_at, a.state_mult
|
||||||
FROM matched_file_changes mfc
|
FROM mr_activity a
|
||||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||||
JOIN reviewer_participation rp ON rp.mr_id = m.id AND rp.username = r.username
|
|
||||||
WHERE r.username IS NOT NULL
|
WHERE r.username IS NOT NULL
|
||||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||||
AND m.state IN ('opened','merged','closed')
|
AND a.activity_ts >= ?2
|
||||||
AND {state_aware_ts} >= ?2
|
AND a.activity_ts < ?4
|
||||||
AND {state_aware_ts} < ?4
|
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
|
-- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
|
||||||
SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
|
SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id,
|
a.mr_id, NULL AS note_id,
|
||||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
a.activity_ts AS seen_at, a.state_mult
|
||||||
FROM matched_file_changes mfc
|
FROM mr_activity a
|
||||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||||
LEFT JOIN reviewer_participation rp ON rp.mr_id = m.id AND rp.username = r.username
|
|
||||||
WHERE rp.username IS NULL -- NOT in participation set
|
WHERE rp.username IS NULL -- NOT in participation set
|
||||||
AND r.username IS NOT NULL
|
AND r.username IS NOT NULL
|
||||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||||
AND m.state IN ('opened','merged','closed')
|
AND a.activity_ts >= ?2
|
||||||
AND {state_aware_ts} >= ?2
|
AND a.activity_ts < ?4
|
||||||
AND {state_aware_ts} < ?4
|
|
||||||
),
|
),
|
||||||
aggregated AS (
|
aggregated AS (
|
||||||
-- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts)
|
-- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts)
|
||||||
SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, mr_state
|
SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult
|
||||||
FROM raw WHERE signal != 'diffnote_reviewer'
|
FROM raw WHERE signal != 'diffnote_reviewer'
|
||||||
GROUP BY username, signal, mr_id
|
GROUP BY username, signal, mr_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
-- Note signals: 1 row per (username, mr_id) with note_count and max_ts
|
-- Note signals: 1 row per (username, mr_id) with note_count and max_ts
|
||||||
SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, mr_state
|
SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult
|
||||||
FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
|
FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
|
||||||
GROUP BY username, mr_id
|
GROUP BY username, mr_id
|
||||||
)
|
)
|
||||||
SELECT username, signal, mr_id, qty, ts, mr_state FROM aggregated WHERE username IS NOT NULL
|
SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `{state_aware_ts}` is the state-aware timestamp expression (defined in the next section), `{path_op}` is either `= ?1` or `LIKE ?1 ESCAPE '\\'` depending on the path query type, `?4` is the `as_of_ms` exclusive upper bound (defaults to `now_ms` when `--as-of` is not specified), and `{reviewer_min_note_chars}` is the configured `reviewer_min_note_chars` value (default 20, inlined as a literal in the SQL string). The `>= ?2 AND < ?4` pattern (half-open interval) ensures that when `--as-of` is set to a past date, events at or after that date are excluded — without this, "future" events would leak in with full weight, breaking reproducibility. The exclusive upper bound avoids edge-case ambiguity when events have timestamps exactly equal to the as-of value.
|
Where `{path_op}` is either `= ?1` or `LIKE ?1 ESCAPE '\\'` depending on the path query type, `?2` is `since_ms`, `?3` is the optional project_id, `?4` is the `as_of_ms` exclusive upper bound (defaults to `now_ms` when `--as-of` is not specified), `?5` is the `closed_mr_multiplier` (default 0.5, bound as a parameter), and `?6` is the configured `reviewer_min_note_chars` value (default 20, bound as a parameter). The `>= ?2 AND < ?4` pattern (half-open interval) ensures that when `--as-of` is set to a past date, events at or after that date are excluded — without this, "future" events would leak in with full weight, breaking reproducibility. The exclusive upper bound avoids edge-case ambiguity when events have timestamps exactly equal to the as-of value.
|
||||||
|
|
||||||
**Rationale for CTE-based dual-path matching**: The previous approach (repeating `OR old_path` in every signal subquery) duplicated the path matching logic 5 times. Factoring it into foundational CTEs (`matched_notes_raw` → `matched_notes`, `matched_file_changes_raw` → `matched_file_changes`) means path matching is defined once, each index branch is explicit, and adding future path resolution logic (e.g., alias chains) only requires changes in one place. The UNION ALL + dedup pattern ensures SQLite uses the optimal index for each path column independently.
|
**Rationale for CTE-based dual-path matching**: The previous approach (repeating `OR old_path` in every signal subquery) duplicated the path matching logic 5 times. Factoring it into foundational CTEs (`matched_notes_raw` → `matched_notes`, `matched_file_changes_raw` → `matched_file_changes`) means path matching is defined once, each index branch is explicit, and adding future path resolution logic (e.g., alias chains) only requires changes in one place. The UNION ALL + dedup pattern ensures SQLite uses the optimal index for each path column independently.
|
||||||
|
|
||||||
@@ -308,7 +326,21 @@ Both columns already exist in the schema (`notes.position_old_path` from migrati
|
|||||||
- **Signal 4a** (`file_reviewer_participated`): User is in `mr_reviewers` AND appears in the `reviewer_participation` CTE (left DiffNotes on the path for that MR). Gets `reviewer_weight` (10) and `reviewer_half_life_days` (90).
|
- **Signal 4a** (`file_reviewer_participated`): User is in `mr_reviewers` AND appears in the `reviewer_participation` CTE (left DiffNotes on the path for that MR). Gets `reviewer_weight` (10) and `reviewer_half_life_days` (90).
|
||||||
- **Signal 4b** (`file_reviewer_assigned`): User is in `mr_reviewers` but NOT in the `reviewer_participation` CTE. Gets `reviewer_assignment_weight` (3) and `reviewer_assignment_half_life_days` (45).
|
- **Signal 4b** (`file_reviewer_assigned`): User is in `mr_reviewers` but NOT in the `reviewer_participation` CTE. Gets `reviewer_assignment_weight` (3) and `reviewer_assignment_half_life_days` (45).
|
||||||
|
|
||||||
### 3a. Path Resolution Probes (who.rs)
|
**Rationale for `mr_activity` CTE**: The previous approach repeated the state-aware CASE expression and `m.state` column in signals 3, 4a, and 4b, with the `closed_mr_multiplier` applied later in Rust by string-matching on `mr_state`. This split was brittle — the CASE expression could drift between signal branches, and per-row state-string handling in Rust was unnecessary indirection. The `mr_activity` CTE defines the timestamp and multiplier once, scoped to matched MRs only (via JOIN with `matched_file_changes`) to avoid materializing the full MR table. Signals 3, 4a, 4b now reference `a.activity_ts` and `a.state_mult` directly. Signals 1 and 2 (DiffNote-based) still compute `state_mult` inline because they join through `discussions`, not `matched_file_changes`, and adding them to `mr_activity` would require a second join path that doesn't simplify anything.
|
||||||
|
|
||||||
|
**Rationale for parameterized `reviewer_min_note_chars` and `closed_mr_multiplier`**: Previous iterations inlined `reviewer_min_note_chars` as a literal in the SQL string and kept `closed_mr_multiplier` in Rust only. Binding both as SQL parameters (`?5` for `closed_mr_multiplier`, `?6` for `reviewer_min_note_chars`) eliminates statement-cache churn (the SQL text is identical regardless of config values), avoids SQL-text variability that complicates EXPLAIN QUERY PLAN analysis, and centralizes the multiplier application in SQL for file-change signals. The DiffNote signals (1, 2) still compute `state_mult` inline because they don't go through `mr_activity`.
|
||||||
|
|
||||||
|
### 3a. Path Canonicalization and Resolution Probes (who.rs)
|
||||||
|
|
||||||
|
**Path canonicalization**: Before any path resolution or scoring, normalize the user's input path via `normalize_query_path()`:
|
||||||
|
- Strip leading `./` (e.g., `./src/foo.rs` → `src/foo.rs`)
|
||||||
|
- Collapse repeated `/` (e.g., `src//foo.rs` → `src/foo.rs`)
|
||||||
|
- Trim leading/trailing whitespace
|
||||||
|
- Preserve trailing `/` only when present — it signals explicit prefix intent
|
||||||
|
|
||||||
|
This is applied once at the top of `run_who()` before `build_path_query()`. The robot JSON `resolved_input` includes both `path_input_original` (raw user input) and `path_input_normalized` (after canonicalization) for debugging transparency. The normalization is purely syntactic — no filesystem lookups, no canonicalization against the database.
|
||||||
|
|
||||||
|
**Path resolution probes**: Rename awareness must extend beyond scoring queries to the path resolution layer. Currently `build_path_query()` (line 457) and `suffix_probe()` (line 584) only check `position_new_path` and `new_path`. If a user queries an old path name, these probes return "not found" and the scoring query never runs.
|
||||||
|
|
||||||
Rename awareness must extend beyond scoring queries to the path resolution layer. Currently `build_path_query()` (line 457) and `suffix_probe()` (line 584) only check `position_new_path` and `new_path`. If a user queries an old path name, these probes return "not found" and the scoring query never runs.
|
Rename awareness must extend beyond scoring queries to the path resolution layer. Currently `build_path_query()` (line 457) and `suffix_probe()` (line 584) only check `position_new_path` and `new_path`. If a user queries an old path name, these probes return "not found" and the scoring query never runs.
|
||||||
|
|
||||||
@@ -337,39 +369,29 @@ WHERE old_path IS NOT NULL
|
|||||||
|
|
||||||
This ensures that querying by an old filename (e.g., `login.rs` after it was renamed to `auth.rs`) still resolves to a usable path for scoring. The UNION deduplicates so the same path appearing in both old and new columns doesn't cause false ambiguity.
|
This ensures that querying by an old filename (e.g., `login.rs` after it was renamed to `auth.rs`) still resolves to a usable path for scoring. The UNION deduplicates so the same path appearing in both old and new columns doesn't cause false ambiguity.
|
||||||
|
|
||||||
**State-aware timestamps for file-change signals (signals 3, 4a, 4b)**: Replace `m.updated_at` with a state-aware expression:
|
**State-aware timestamps for file-change signals (signals 3, 4a, 4b)**: Centralized in the `mr_activity` CTE (see section 3). The CASE expression uses `merged_at` for merged MRs, `closed_at` for closed MRs, and `updated_at` for open MRs, with `created_at` as fallback when the preferred timestamp is NULL.
|
||||||
|
|
||||||
```sql
|
|
||||||
CASE
|
|
||||||
WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
|
||||||
WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
|
||||||
ELSE COALESCE(m.updated_at, m.created_at) -- opened / other
|
|
||||||
END AS activity_ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rationale**: `updated_at` is noisy for merged MRs — it changes on label edits, title changes, rebases, and metadata touches, creating false recency. `merged_at` is the best indicator of when code expertise was formed (the moment the code entered the branch). But for **open MRs**, `updated_at` is actually the right signal because it reflects ongoing active work. `closed_at` anchors closed-without-merge MRs to their closure time (these represent review effort even if the code was abandoned). Each state gets the timestamp that best represents when expertise was last exercised.
|
**Rationale**: `updated_at` is noisy for merged MRs — it changes on label edits, title changes, rebases, and metadata touches, creating false recency. `merged_at` is the best indicator of when code expertise was formed (the moment the code entered the branch). But for **open MRs**, `updated_at` is actually the right signal because it reflects ongoing active work. `closed_at` anchors closed-without-merge MRs to their closure time (these represent review effort even if the code was abandoned). Each state gets the timestamp that best represents when expertise was last exercised.
|
||||||
|
|
||||||
### 4. Rust-Side Aggregation (who.rs)
|
### 4. Rust-Side Aggregation (who.rs)
|
||||||
|
|
||||||
For each username, accumulate into a struct with:
|
For each username, accumulate into a struct with:
|
||||||
- **Author MRs**: `HashMap<i64, (i64, String)>` (mr_id -> (max timestamp, mr_state)) from `diffnote_author` + `file_author` signals
|
- **Author MRs**: `HashMap<i64, (i64, f64)>` (mr_id -> (max timestamp, state_mult)) from `diffnote_author` + `file_author` signals
|
||||||
- **Reviewer Participated MRs**: `HashMap<i64, (i64, String)>` from `diffnote_reviewer` + `file_reviewer_participated` signals
|
- **Reviewer Participated MRs**: `HashMap<i64, (i64, f64)>` from `diffnote_reviewer` + `file_reviewer_participated` signals
|
||||||
- **Reviewer Assigned-Only MRs**: `HashMap<i64, (i64, String)>` from `file_reviewer_assigned` signals (excluding any MR already in participated set)
|
- **Reviewer Assigned-Only MRs**: `HashMap<i64, (i64, f64)>` from `file_reviewer_assigned` signals (excluding any MR already in participated set)
|
||||||
- **Notes per MR**: `HashMap<i64, (u32, i64, String)>` (mr_id -> (count, max_ts, mr_state)) from `note_group` rows in the aggregated query (already grouped per user+MR with note_count in `qty`). Used for `log2(1 + count)` diminishing returns.
|
- **Notes per MR**: `HashMap<i64, (u32, i64, f64)>` (mr_id -> (count, max_ts, state_mult)) from `note_group` rows in the aggregated query (already grouped per user+MR with note_count in `qty`). Used for `log2(1 + count)` diminishing returns.
|
||||||
- **Last seen**: max of all timestamps
|
- **Last seen**: max of all timestamps
|
||||||
- **Components** (when `--explain-score`): Track per-component f64 subtotals for `author`, `reviewer_participated`, `reviewer_assigned`, `notes`
|
- **Components** (when `--explain-score`): Track per-component f64 subtotals for `author`, `reviewer_participated`, `reviewer_assigned`, `notes`
|
||||||
|
|
||||||
The `mr_state` field from each SQL row is stored alongside the timestamp so the Rust-side can apply `closed_mr_multiplier` when `mr_state == "closed"`.
|
The `state_mult` field from each SQL row (already computed in SQL as 1.0 for merged/open or `closed_mr_multiplier` for closed) is stored alongside the timestamp — no string-matching on MR state needed in Rust.
|
||||||
|
|
||||||
Compute score as `f64` with **deterministic contribution ordering**: within each signal type, sort contributions by `(mr_id ASC)` before summing. This eliminates platform-dependent HashMap iteration order as a source of f64 rounding variance near ties, ensuring CI reproducibility without the complexity of compensated summation (Neumaier/Kahan). Each MR-level contribution is multiplied by `closed_mr_multiplier` (default 0.5) when the MR's state is `"closed"`:
|
Compute score as `f64` with **deterministic contribution ordering**: within each signal type, sort contributions by `(mr_id ASC)` before summing. This eliminates platform-dependent HashMap iteration order as a source of f64 rounding variance near ties, ensuring CI reproducibility without the complexity of compensated summation (Neumaier/Kahan). Each MR-level contribution is multiplied by its `state_mult` (already computed in SQL):
|
||||||
```
|
```
|
||||||
state_mult(mr) = if mr.state == "closed" { closed_mr_multiplier } else { 1.0 }
|
|
||||||
|
|
||||||
raw_score =
|
raw_score =
|
||||||
sum(author_weight * state_mult(mr) * decay(now - ts, author_hl) for (mr, ts) in author_mrs)
|
sum(author_weight * state_mult * decay(now - ts, author_hl) for (mr, ts, state_mult) in author_mrs)
|
||||||
+ sum(reviewer_weight * state_mult(mr) * decay(now - ts, reviewer_hl) for (mr, ts) in reviewer_participated)
|
+ sum(reviewer_weight * state_mult * decay(now - ts, reviewer_hl) for (mr, ts, state_mult) in reviewer_participated)
|
||||||
+ sum(reviewer_assignment_weight * state_mult(mr) * decay(now - ts, reviewer_assignment_hl) for (mr, ts) in reviewer_assigned)
|
+ sum(reviewer_assignment_weight * state_mult * decay(now - ts, reviewer_assignment_hl) for (mr, ts, state_mult) in reviewer_assigned)
|
||||||
+ sum(note_bonus * state_mult(mr) * log2(1 + count) * decay(now - ts, note_hl) for (mr, count, ts) in notes_per_mr)
|
+ sum(note_bonus * state_mult * log2(1 + count) * decay(now - ts, note_hl) for (mr, count, ts, state_mult) in notes_per_mr)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why include closed MRs?** A closed-without-merge MR still represents review effort and code familiarity — the reviewer read the diff, left comments, and engaged with the code even though it was ultimately abandoned. Excluding closed MRs entirely (the previous plan's approach) discarded this signal. The `closed_mr_multiplier` (default 0.5) halves the contribution, reflecting that the code never landed but the reviewer's cognitive engagement was real. This also eliminates the dead-code inconsistency where the state-aware CASE expression handled `closed` but the WHERE clause excluded it.
|
**Why include closed MRs?** A closed-without-merge MR still represents review effort and code familiarity — the reviewer read the diff, left comments, and engaged with the code even though it was ultimately abandoned. Excluding closed MRs entirely (the previous plan's approach) discarded this signal. The `closed_mr_multiplier` (default 0.5) halves the contribution, reflecting that the code never landed but the reviewer's cognitive engagement was real. This also eliminates the dead-code inconsistency where the state-aware CASE expression handled `closed` but the WHERE clause excluded it.
|
||||||
@@ -458,9 +480,16 @@ CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
|
|||||||
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
||||||
ON notes(discussion_id, author_username, created_at)
|
ON notes(discussion_id, author_username, created_at)
|
||||||
WHERE note_type = 'DiffNote' AND is_system = 0;
|
WHERE note_type = 'DiffNote' AND is_system = 0;
|
||||||
|
|
||||||
|
-- Support path resolution probes on old_path (build_path_query() and suffix_probe())
|
||||||
|
-- The existing idx_notes_diffnote_path_created covers new_path probes, but old_path probes
|
||||||
|
-- need their own index since probes don't constrain author_username.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
|
ON notes(position_old_path, project_id, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rationale**: The existing indexes cover `position_new_path` and `new_path` but not their `old_path` counterparts. Without these, the `OR old_path` clauses would force table scans on renamed files. The `reviewer_participation` CTE joins `matched_notes` -> `discussions` -> `merge_requests`, so an index on `(discussion_id, author_username)` speeds up the CTE materialization.
|
**Rationale**: The existing indexes cover `position_new_path` and `new_path` but not their `old_path` counterparts. Without these, the `OR old_path` clauses would force table scans on renamed files. The `reviewer_participation` CTE joins `matched_notes` -> `discussions` -> `merge_requests`, so an index on `(discussion_id, author_username)` speeds up the CTE materialization. The `idx_notes_old_path_project_created` index supports path resolution probes (`build_path_query()` and `suffix_probe()`) which run existence/path-only checks without constraining `author_username` — the scoring-oriented `idx_notes_old_path_author` has `author_username` as the second column, which is suboptimal for these probes.
|
||||||
|
|
||||||
**Schema note**: The `notes` table uses `discussion_id` as its FK to `discussions`, which in turn has `merge_request_id`. There is no `noteable_id` column on `notes`. The previous plan revision incorrectly referenced `noteable_id` — this is corrected.
|
**Schema note**: The `notes` table uses `discussion_id` as its FK to `discussions`, which in turn has `merge_request_id`. There is no `noteable_id` column on `notes`. The previous plan revision incorrectly referenced `noteable_id` — this is corrected.
|
||||||
|
|
||||||
@@ -526,6 +555,14 @@ Add timestamp-aware variants:
|
|||||||
|
|
||||||
**`test_null_timestamp_fallback_to_created_at`**: Insert a merged MR with `merged_at = NULL` (edge case: old data before the column was populated). The state-aware timestamp should fall back to `created_at`. Verify the score reflects `created_at`, not 0 or a panic.
|
**`test_null_timestamp_fallback_to_created_at`**: Insert a merged MR with `merged_at = NULL` (edge case: old data before the column was populated). The state-aware timestamp should fall back to `created_at`. Verify the score reflects `created_at`, not 0 or a panic.
|
||||||
|
|
||||||
|
**`test_path_normalization_handles_dot_and_double_slash`**: Call `normalize_query_path("./src//foo.rs")` — should return `"src/foo.rs"`. Call `normalize_query_path(" src/bar.rs ")` — should return `"src/bar.rs"`. Call `normalize_query_path("src/foo.rs")` — should return unchanged (already normalized). Call `normalize_query_path("")` — should return `""` (empty input passes through).
|
||||||
|
|
||||||
|
**`test_path_normalization_preserves_prefix_semantics`**: Call `normalize_query_path("./src/dir/")` — should return `"src/dir/"` (trailing slash preserved for prefix intent). Call `normalize_query_path("src/dir")` — should return `"src/dir"` (no trailing slash = file, not prefix).
|
||||||
|
|
||||||
|
**`test_config_validation_rejects_absurd_half_life`**: `ScoringConfig` with `author_half_life_days = 5000` (>3650 cap) should return `ConfigInvalid` error. Similarly, `reviewer_min_note_chars = 5000` (>4096 cap) should fail.
|
||||||
|
|
||||||
|
**`test_config_validation_rejects_nan_multiplier`**: `ScoringConfig` with `closed_mr_multiplier = f64::NAN` should return `ConfigInvalid` error. Same for `f64::INFINITY`.
|
||||||
|
|
||||||
#### Invariant tests (regression safety for ranking systems)
|
#### Invariant tests (regression safety for ranking systems)
|
||||||
|
|
||||||
**`test_score_monotonicity_by_age`**: For any single signal type, an older timestamp must never produce a higher score than a newer timestamp with the same weight and half-life. Generate N random (age, half_life) pairs and assert `decay(older) <= decay(newer)` for all.
|
**`test_score_monotonicity_by_age`**: For any single signal type, an older timestamp must never produce a higher score than a newer timestamp with the same weight and half-life. Generate N random (age, half_life) pairs and assert `decay(older) <= decay(newer)` for all.
|
||||||
@@ -554,6 +591,8 @@ The `test_expert_scoring_weights_are_configurable` test needs `..Default::defaul
|
|||||||
- Confirm that `matched_notes_raw` branch 1 uses the existing new_path index and branch 2 uses `idx_notes_old_path_author` (not a full table scan on either branch)
|
- Confirm that `matched_notes_raw` branch 1 uses the existing new_path index and branch 2 uses `idx_notes_old_path_author` (not a full table scan on either branch)
|
||||||
- Confirm that `matched_file_changes_raw` branch 1 uses `idx_mfc_new_path_project_mr` and branch 2 uses `idx_mfc_old_path_project_mr`
|
- Confirm that `matched_file_changes_raw` branch 1 uses `idx_mfc_new_path_project_mr` and branch 2 uses `idx_mfc_old_path_project_mr`
|
||||||
- Confirm that `reviewer_participation` CTE uses `idx_notes_diffnote_discussion_author`
|
- Confirm that `reviewer_participation` CTE uses `idx_notes_diffnote_discussion_author`
|
||||||
|
- Confirm that `mr_activity` CTE joins `merge_requests` via primary key from `matched_file_changes`
|
||||||
|
- Confirm that path resolution probes (old_path leg) use `idx_notes_old_path_project_created`
|
||||||
- Document the observed plan in a comment near the SQL for future regression reference
|
- Document the observed plan in a comment near the SQL for future regression reference
|
||||||
7. Performance baseline (manual, not CI-gated):
|
7. Performance baseline (manual, not CI-gated):
|
||||||
- Run `time cargo run --release -- who --path <exact-path>` on the real database for exact, prefix, and suffix modes
|
- Run `time cargo run --release -- who --path <exact-path>` on the real database for exact, prefix, and suffix modes
|
||||||
@@ -571,6 +610,7 @@ The `test_expert_scoring_weights_are_configurable` test needs `..Default::defaul
|
|||||||
- Spot-check that reviewers who only left "LGTM"-style notes are classified as assigned-only (not participated)
|
- Spot-check that reviewers who only left "LGTM"-style notes are classified as assigned-only (not participated)
|
||||||
- Verify closed MRs contribute at ~50% of equivalent merged MR scores via `--explain-score`
|
- Verify closed MRs contribute at ~50% of equivalent merged MR scores via `--explain-score`
|
||||||
- If the project has known bot accounts (e.g., renovate-bot), add them to `excluded_usernames` config and verify they no longer appear in results. Run again with `--include-bots` to confirm they reappear.
|
- If the project has known bot accounts (e.g., renovate-bot), add them to `excluded_usernames` config and verify they no longer appear in results. Run again with `--include-bots` to confirm they reappear.
|
||||||
|
- Test path normalization: `who --path ./src//foo.rs` and `who --path src/foo.rs` should produce identical results
|
||||||
|
|
||||||
## Accepted from External Review
|
## Accepted from External Review
|
||||||
|
|
||||||
@@ -614,6 +654,14 @@ Ideas incorporated from ChatGPT review (feedback-1 through feedback-4) that genu
|
|||||||
- **Performance baseline SLOs**: Added manual performance baseline step to verification — record timings for exact/prefix/suffix modes and flag >2x regressions. Kept lightweight (no CI gating, no synthetic benchmarks) to match the project's current maturity.
|
- **Performance baseline SLOs**: Added manual performance baseline step to verification — record timings for exact/prefix/suffix modes and flag >2x regressions. Kept lightweight (no CI gating, no synthetic benchmarks) to match the project's current maturity.
|
||||||
- **New tests**: `test_as_of_exclusive_upper_bound`, `test_excluded_usernames_filters_bots`, `test_include_bots_flag_disables_filtering`, `test_deterministic_accumulation_order` — cover the newly-accepted features.
|
- **New tests**: `test_as_of_exclusive_upper_bound`, `test_excluded_usernames_filters_bots`, `test_include_bots_flag_disables_filtering`, `test_deterministic_accumulation_order` — cover the newly-accepted features.
|
||||||
|
|
||||||
|
**From feedback-6 (ChatGPT review):**
|
||||||
|
- **Centralized `mr_activity` CTE**: The state-aware timestamp CASE expression and `closed_mr_multiplier` were repeated across signals 3, 4a, 4b with the multiplier applied later in Rust via string-matching on `mr_state`. This was brittle — the CASE could drift between branches and the Rust-side string matching was unnecessary indirection. A single `mr_activity` CTE defines both `activity_ts` and `state_mult` once, scoped to matched MRs only (via JOIN with `matched_file_changes`). Signals 1 and 2 still compute `state_mult` inline because they join through `discussions`, not `matched_file_changes`.
|
||||||
|
- **Parameterized `reviewer_min_note_chars` and `closed_mr_multiplier`**: Previously `reviewer_min_note_chars` was inlined as a literal in the SQL string and `closed_mr_multiplier` was applied only in Rust. Binding both as SQL parameters (`?5` for `closed_mr_multiplier`, `?6` for `reviewer_min_note_chars`) eliminates statement-cache churn, ensures identical SQL text regardless of config values, and simplifies EXPLAIN QUERY PLAN analysis.
|
||||||
|
- **Tightened config validation**: Added upper bounds — `*_half_life_days <= 3650` (10-year safety cap), `reviewer_min_note_chars <= 4096`, and `closed_mr_multiplier` must be finite (not NaN/Inf). These prevent absurd configurations from silently producing nonsensical results.
|
||||||
|
- **Path canonicalization via `normalize_query_path()`**: Inputs like `./src//foo.rs` or whitespace-padded paths could fail path resolution even when the file exists in the database. A simple syntactic normalization (strip `./`, collapse `//`, trim whitespace, preserve trailing `/`) runs before `build_path_query()` to reduce false negatives. No filesystem or database lookups — purely string manipulation.
|
||||||
|
- **Probe-optimized `idx_notes_old_path_project_created` index**: The scoring-oriented `idx_notes_old_path_author` index has `author_username` as its second column, which is suboptimal for path resolution probes that don't constrain author. A dedicated probe index on `(position_old_path, project_id, created_at)` ensures `build_path_query()` and `suffix_probe()` old_path lookups are efficient.
|
||||||
|
- **New tests**: `test_path_normalization_handles_dot_and_double_slash`, `test_path_normalization_preserves_prefix_semantics`, `test_config_validation_rejects_absurd_half_life`, `test_config_validation_rejects_nan_multiplier` — cover the path canonicalization and tightened validation logic.
|
||||||
|
|
||||||
## Rejected Ideas (with rationale)
|
## Rejected Ideas (with rationale)
|
||||||
|
|
||||||
These suggestions were considered during review but explicitly excluded from this iteration:
|
These suggestions were considered during review but explicitly excluded from this iteration:
|
||||||
@@ -635,3 +683,6 @@ These suggestions were considered during review but explicitly excluded from thi
|
|||||||
- **Full evidence drill-down in `--explain-score`** (feedback-5 #8): Proposes `--explain-score=summary|full` with per-MR evidence rows. Already rejected in feedback-2 #7. Component totals are sufficient for v1 debugging — they answer "which signal type drives this user's score." Per-MR drill-down requires additional SQL queries and significant output format complexity. Deferred unless component breakdowns prove insufficient.
|
- **Full evidence drill-down in `--explain-score`** (feedback-5 #8): Proposes `--explain-score=summary|full` with per-MR evidence rows. Already rejected in feedback-2 #7. Component totals are sufficient for v1 debugging — they answer "which signal type drives this user's score." Per-MR drill-down requires additional SQL queries and significant output format complexity. Deferred unless component breakdowns prove insufficient.
|
||||||
- **Neumaier compensated summation** (feedback-5 #7 partial): Accepted the sorting aspect for deterministic ordering, but rejected Neumaier/Kahan compensated summation. At the scale of dozens to low hundreds of contributions per user, the rounding error from naive f64 summation is on the order of 1e-14 — several orders of magnitude below any meaningful score difference. Compensated summation adds code complexity and a maintenance burden for no practical benefit at this scale.
|
- **Neumaier compensated summation** (feedback-5 #7 partial): Accepted the sorting aspect for deterministic ordering, but rejected Neumaier/Kahan compensated summation. At the scale of dozens to low hundreds of contributions per user, the rounding error from naive f64 summation is on the order of 1e-14 — several orders of magnitude below any meaningful score difference. Compensated summation adds code complexity and a maintenance burden for no practical benefit at this scale.
|
||||||
- **Automated CI benchmark gate** (feedback-5 #10 partial): Accepted manual performance baselines, but rejected automated CI regression gating with synthetic fixtures (100k/1M/5M notes). Building and maintaining benchmark infrastructure is a significant investment that's premature for a CLI tool with ~3 users. Manual timing checks during development are sufficient until performance becomes a real concern.
|
- **Automated CI benchmark gate** (feedback-5 #10 partial): Accepted manual performance baselines, but rejected automated CI regression gating with synthetic fixtures (100k/1M/5M notes). Building and maintaining benchmark infrastructure is a significant investment that's premature for a CLI tool with ~3 users. Manual timing checks during development are sufficient until performance becomes a real concern.
|
||||||
|
- **Epsilon-based tie buckets for ranking** (feedback-6 #4) — rejected because the plan already has deterministic contribution ordering by `mr_id` within each signal type, which eliminates HashMap-iteration nondeterminism. Platform-dependent `powf` differences at the scale of dozens to hundreds of contributions per user are sub-epsilon (order of 1e-15). If two users genuinely score within 1e-9 of each other, the existing tiebreak by `(last_seen DESC, username ASC)` is already meaningful and deterministic. Adding a bucketing layer introduces a magic epsilon constant and floor operation for a problem that doesn't manifest in practice.
|
||||||
|
- **`--diagnose-score` aggregated diagnostics flag** (feedback-6 #5) — rejected because this is diagnostic/debugging tooling that adds a new flag, new output format, and new counting logic (matched_notes_raw_count, dedup_count, window exclusions, etc.) across the SQL pipeline. The existing `--explain-score` component breakdown + manual EXPLAIN QUERY PLAN verification already covers the debugging need. The additional SQL instrumentation required (counting rows at each CTE stage) would complicate the query for a feature with unclear demand. A v2 addition if operational debugging becomes a recurring need.
|
||||||
|
- **Multi-path expert scoring (`--path` repeatable)** (feedback-6 #7) — rejected because this is a feature expansion, not a plan improvement for the time-decay model. Multi-path requires a `requested_paths` CTE, modified dedup logic keyed on `(username, signal, mr_id)` across paths, CLI parsing changes for repeatable `--path` and `--path-file`, and new test cases for overlap/prefix/dedup semantics. This is a separate bead/feature that should be designed independently — it's orthogonal to time-decay scoring and can be added later without requiring any changes to the decay model.
|
||||||
|
|||||||
214
plans/tui-prd-v2-frankentui.feedback-10.md
Normal file
214
plans/tui-prd-v2-frankentui.feedback-10.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
I found 9 high-impact revisions that materially improve correctness, robustness, and usability without reintroducing anything in `## Rejected Recommendations`.
|
||||||
|
|
||||||
|
### 1. Prevent stale async overwrites on **all** screens (not just search)
|
||||||
|
Right now, only `SearchExecuted` is generation-guarded. `IssueListLoaded`, `MrListLoaded`, `IssueDetailLoaded`, etc. can still race and overwrite newer state after rapid navigation/filtering. This is the biggest correctness risk in the current design.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ message.rs
|
||||||
|
- IssueListLoaded(Vec<IssueRow>),
|
||||||
|
+ IssueListLoaded { generation: u64, rows: Vec<IssueRow> },
|
||||||
|
@@
|
||||||
|
- MrListLoaded(Vec<MrRow>),
|
||||||
|
+ MrListLoaded { generation: u64, rows: Vec<MrRow> },
|
||||||
|
@@
|
||||||
|
- IssueDetailLoaded { key: EntityKey, detail: IssueDetail },
|
||||||
|
- MrDetailLoaded { key: EntityKey, detail: MrDetail },
|
||||||
|
+ IssueDetailLoaded { generation: u64, key: EntityKey, detail: IssueDetail },
|
||||||
|
+ MrDetailLoaded { generation: u64, key: EntityKey, detail: MrDetail },
|
||||||
|
|
||||||
|
@@ update()
|
||||||
|
- Msg::IssueListLoaded(result) => {
|
||||||
|
+ Msg::IssueListLoaded { generation, rows } => {
|
||||||
|
+ if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation) {
|
||||||
|
+ return Cmd::none();
|
||||||
|
+ }
|
||||||
|
self.state.set_loading(false);
|
||||||
|
- self.state.issue_list.set_result(result);
|
||||||
|
+ self.state.issue_list.set_result(rows);
|
||||||
|
Cmd::none()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Make cancellation safe with task-owned SQLite interrupt handles
|
||||||
|
The plan mentions `sqlite3_interrupt()` but uses pooled shared reader connections. Interrupting a shared connection can cancel unrelated work. Use per-task reader leases and store `InterruptHandle` in `TaskHandle`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ DbManager
|
||||||
|
- readers: Vec<Mutex<Connection>>,
|
||||||
|
+ readers: Vec<Mutex<Connection>>,
|
||||||
|
+ // task-scoped interrupt handles prevent cross-task cancellation bleed
|
||||||
|
+ // each dispatched query receives an owned ReaderLease
|
||||||
|
|
||||||
|
+pub struct ReaderLease {
|
||||||
|
+ conn: Connection,
|
||||||
|
+ interrupt: rusqlite::InterruptHandle,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+impl DbManager {
|
||||||
|
+ pub fn lease_reader(&self) -> Result<ReaderLease, LoreError> { ... }
|
||||||
|
+}
|
||||||
|
|
||||||
|
@@ TaskHandle
|
||||||
|
pub struct TaskHandle {
|
||||||
|
pub key: TaskKey,
|
||||||
|
pub generation: u64,
|
||||||
|
pub cancel: Arc<CancelToken>,
|
||||||
|
+ pub interrupt: Option<rusqlite::InterruptHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ cancellation
|
||||||
|
-Query interruption: ... fires sqlite3_interrupt() on the connection.
|
||||||
|
+Query interruption: cancel triggers the task's owned InterruptHandle only.
|
||||||
|
+No shared-connection interrupt is permitted.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Harden keyset pagination for multi-project and sort changes
|
||||||
|
`updated_at + iid` cursor is not enough when rows share timestamps across projects or sort mode changes. This can duplicate/skip rows.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ issue_list.rs
|
||||||
|
-pub struct IssueCursor {
|
||||||
|
- pub updated_at: i64,
|
||||||
|
- pub iid: i64,
|
||||||
|
-}
|
||||||
|
+pub struct IssueCursor {
|
||||||
|
+ pub sort_field: SortField,
|
||||||
|
+ pub sort_order: SortOrder,
|
||||||
|
+ pub updated_at: Option<i64>,
|
||||||
|
+ pub created_at: Option<i64>,
|
||||||
|
+ pub iid: i64,
|
||||||
|
+ pub project_id: i64, // deterministic tie-breaker
|
||||||
|
+ pub filter_hash: u64, // invalidates stale cursors on filter mutation
|
||||||
|
+}
|
||||||
|
|
||||||
|
@@ pagination section
|
||||||
|
-Windowed keyset pagination ...
|
||||||
|
+Windowed keyset pagination uses deterministic tuple ordering:
|
||||||
|
+`ORDER BY <primary_sort>, project_id, iid`.
|
||||||
|
+Cursor is rejected if `filter_hash` or sort tuple mismatches current query.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Replace ad-hoc filter parsing with a small typed DSL
|
||||||
|
Current `split_whitespace()` parser is brittle and silently lossy. Add quoted values, negation, and strict parse errors.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ filter_bar.rs
|
||||||
|
- fn parse_tokens(&mut self) {
|
||||||
|
- let text = self.input.value().to_string();
|
||||||
|
- self.tokens = text.split_whitespace().map(|chunk| { ... }).collect();
|
||||||
|
- }
|
||||||
|
+ fn parse_tokens(&mut self) {
|
||||||
|
+ // grammar (v1):
|
||||||
|
+ // term := [ "-" ] (field ":" value | quoted_text | bare_text)
|
||||||
|
+ // value := quoted | unquoted
|
||||||
|
+ // examples:
|
||||||
|
+ // state:opened label:"P1 blocker" -author:bot since:14d
|
||||||
|
+ self.tokens = filter_dsl::parse(self.input.value())?;
|
||||||
|
+ }
|
||||||
|
|
||||||
|
@@ section 8 / keybindings-help
|
||||||
|
+Filter parser surfaces actionable inline diagnostics with cursor position,
|
||||||
|
+and never silently drops unknown fields.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Add render caches for markdown/tree shaping
|
||||||
|
Markdown and tree shaping are currently recomputed on every frame in several snippets. Cache render artifacts by `(entity, width, theme, content_hash)` to protect frame time.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ module structure
|
||||||
|
+ render_cache.rs # Width/theme/content-hash keyed cache for markdown + tree layouts
|
||||||
|
|
||||||
|
@@ Assumptions / Performance
|
||||||
|
+Detail and search preview rendering uses memoized render artifacts.
|
||||||
|
+Cache invalidation triggers: content hash change, terminal width change, theme change.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Use one-shot timers for debounce/prefix timeout
|
||||||
|
`Every` is periodic; it wakes repeatedly and can produce edge-case repeated firings. One-shot subscriptions are cleaner and cheaper.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ subscriptions()
|
||||||
|
- if self.state.search.debounce_pending() {
|
||||||
|
- subs.push(Box::new(
|
||||||
|
- Every::with_id(3, Duration::from_millis(200), move || {
|
||||||
|
- Msg::SearchDebounceFired { generation }
|
||||||
|
- })
|
||||||
|
- ));
|
||||||
|
- }
|
||||||
|
+ if self.state.search.debounce_pending() {
|
||||||
|
+ subs.push(Box::new(
|
||||||
|
+ After::with_id(3, Duration::from_millis(200), move || {
|
||||||
|
+ Msg::SearchDebounceFired { generation }
|
||||||
|
+ })
|
||||||
|
+ ));
|
||||||
|
+ }
|
||||||
|
|
||||||
|
@@ InputMode GoPrefix timeout
|
||||||
|
-The tick subscription compares clock instant...
|
||||||
|
+GoPrefix timeout is a one-shot `After(500ms)` tied to prefix generation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. New feature: list “Quick Peek” panel (`Space`) for triage speed
|
||||||
|
This adds immediate value without v2-level scope. Users can inspect selected issue/MR metadata/snippet without entering detail and coming back.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ 5.2 Issue List
|
||||||
|
-Interaction: Enter detail
|
||||||
|
+Interaction: Enter detail, Space quick-peek (toggle right preview pane)
|
||||||
|
|
||||||
|
@@ 5.4 MR List
|
||||||
|
+Quick Peek mode mirrors Issue List: metadata + first discussion snippet + cross-refs.
|
||||||
|
|
||||||
|
@@ 8.2 List Screens
|
||||||
|
| `Enter` | Open selected item |
|
||||||
|
+| `Space` | Toggle Quick Peek panel for selected row |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Upgrade compatibility handshake from integer to machine-readable contract
|
||||||
|
Single integer compat is too coarse for real drift detection. Keep it simple but structured.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ Nightly Rust Strategy / Compatibility contract
|
||||||
|
- 1. Binary compat version (`lore-tui --compat-version`) — integer check ...
|
||||||
|
+ 1. Binary compat contract (`lore-tui --compat-json`) — JSON:
|
||||||
|
+ `{ "protocol": 1, "compat_version": 2, "min_schema": 14, "max_schema": 16, "build": "..." }`
|
||||||
|
+ `lore` validates protocol + compat + schema range before spawn.
|
||||||
|
|
||||||
|
@@ CLI integration
|
||||||
|
-fn validate_tui_compat(...) { ... --compat-version ... }
|
||||||
|
+fn validate_tui_compat(...) { ... --compat-json ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Fix sync stream bug and formalize progress coalescing
|
||||||
|
The current snippet calls `try_send` for progress twice in one callback path and depth math is wrong. Also progress spam should be coalesced by lane.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ start_sync_task()
|
||||||
|
- let current_depth = 2048 - tx.try_send(Msg::SyncProgress(event.clone()))
|
||||||
|
- .err().map_or(0, |_| 1);
|
||||||
|
- max_queue_depth = max_queue_depth.max(current_depth);
|
||||||
|
- if tx.try_send(Msg::SyncProgress(event.clone())).is_err() {
|
||||||
|
+ // coalesce by lane key at <=30Hz; one send attempt per flush
|
||||||
|
+ coalescer.update(event.clone());
|
||||||
|
+ if let Some(batch) = coalescer.flush_ready() {
|
||||||
|
+ if tx.try_send(Msg::SyncProgressBatch(batch)).is_err() {
|
||||||
|
dropped_count += 1;
|
||||||
|
let _ = tx.try_send(Msg::SyncBackpressureDrop);
|
||||||
|
+ } else {
|
||||||
|
+ max_queue_depth = max_queue_depth.max(observed_queue_depth());
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated patch-style rewrite of Sections `4.x`, `5.2/5.4`, `8.2`, `9.3`, and `10.x` so you can drop it directly into iteration 10.
|
||||||
177
plans/tui-prd-v2-frankentui.feedback-11.md
Normal file
177
plans/tui-prd-v2-frankentui.feedback-11.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
I reviewed the full PRD and avoided everything listed under `## Rejected Recommendations`.
|
||||||
|
These are the highest-impact revisions I’d make.
|
||||||
|
|
||||||
|
1. Stable list pagination via snapshot fences
|
||||||
|
Why this improves the plan: your keyset cursor is deterministic for sort/filter, but still vulnerable to duplicates/skips if sync writes land between page fetches. Add a per-browse snapshot fence so one browse session sees a stable dataset.
|
||||||
|
Tradeoff: newest rows are hidden until refresh, which is correct for deterministic triage.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 5.2 Issue List
|
||||||
|
- **Pagination:** Windowed keyset pagination with explicit cursor state.
|
||||||
|
+ **Pagination:** Windowed keyset pagination with explicit cursor state.
|
||||||
|
+ **Snapshot fence:** On list entry, capture `snapshot_upper_updated_at` (ms) and pin all
|
||||||
|
+ list-page queries to `updated_at <= snapshot_upper_updated_at`. This guarantees no duplicate
|
||||||
|
+ or skipped rows during scrolling even if sync writes occur concurrently.
|
||||||
|
+ A "new data available" badge appears when a newer sync completes; `r` refreshes the fence.
|
||||||
|
|
||||||
|
@@ 5.4 MR List
|
||||||
|
- **Pagination:** Same windowed keyset pagination strategy as Issue List.
|
||||||
|
+ **Pagination:** Same strategy plus snapshot fence (`updated_at <= snapshot_upper_updated_at`)
|
||||||
|
+ for deterministic cross-page traversal under concurrent sync writes.
|
||||||
|
|
||||||
|
@@ 4.7 Navigation Stack Implementation
|
||||||
|
+ Browsing sessions carry a per-screen `BrowseSnapshot` token to preserve stable ordering
|
||||||
|
+ until explicit refresh or screen re-entry.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Query budgets and soft deadlines
|
||||||
|
Why this improves the plan: currently “slow query” is handled mostly by cancellation and stale-drop. Add explicit latency budgets so UI responsiveness stays predictable under worst-case filters.
|
||||||
|
Tradeoff: sometimes user gets partial/truncated results first, followed by full results on retry/refine.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.5 Async Action System
|
||||||
|
+ #### 4.5.2 Query Budgets and Soft Deadlines
|
||||||
|
+ Each query type gets a budget:
|
||||||
|
+ - list window fetch: 120ms target, 250ms hard deadline
|
||||||
|
+ - detail phase-1 metadata: 75ms target, 150ms hard deadline
|
||||||
|
+ - search lexical/hybrid: 250ms hard deadline
|
||||||
|
+ On hard deadline breach, return `QueryDegraded { truncated: true }` and show inline badge:
|
||||||
|
+ "results truncated; refine filter or press r to retry full".
|
||||||
|
+ Implementation uses SQLite progress handler + per-task interrupt deadline.
|
||||||
|
|
||||||
|
@@ 9.3 Phase 0 — Toolchain Gate
|
||||||
|
+ 26. Query deadline behavior validated: hard deadline cancels query and renders degraded badge
|
||||||
|
+ without blocking input loop.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Targeted cache invalidation and prewarm after sync
|
||||||
|
Why this improves the plan: `invalidate_all()` after sync throws away hot detail cache and hurts the exact post-sync workflow you optimized for. Invalidate only changed keys and prewarm likely-next entities.
|
||||||
|
Tradeoff: slightly more bookkeeping in sync result handling.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.1 Module Structure
|
||||||
|
- entity_cache.rs # Bounded LRU cache ... Invalidated on sync completion.
|
||||||
|
+ entity_cache.rs # Bounded LRU cache with selective invalidation by changed EntityKey
|
||||||
|
+ # and optional post-sync prewarm of top changed entities.
|
||||||
|
|
||||||
|
@@ 4.4 App — Implementing the Model Trait (Msg::SyncCompleted)
|
||||||
|
- // Invalidate entity cache — synced data may have changed.
|
||||||
|
- self.entity_cache.invalidate_all();
|
||||||
|
+ // Selective invalidation: evict only changed entities from sync delta.
|
||||||
|
+ self.entity_cache.invalidate_keys(&result.changed_entity_keys);
|
||||||
|
+ // Prewarm top N changed/new entities for immediate post-sync triage.
|
||||||
|
+ self.enqueue_cache_prewarm(&result.changed_entity_keys);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Exact “what changed” navigation without new DB tables
|
||||||
|
Why this improves the plan: your summary currently uses timestamp filter; this can include unrelated updates and miss edge cases. Keep an in-memory delta ledger per sync run and navigate by exact IDs.
|
||||||
|
Tradeoff: small memory overhead per run; no schema migration required.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 5.9 Sync (Summary mode)
|
||||||
|
-- `i` navigates to Issue List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
||||||
|
-- `m` navigates to MR List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
||||||
|
+- `i` navigates to Issue List filtered by exact issue IDs changed in this sync run
|
||||||
|
+- `m` navigates to MR List filtered by exact MR IDs changed in this sync run
|
||||||
|
+ (fallback to timestamp filter only if run delta not available)
|
||||||
|
|
||||||
|
@@ 10.1 New Files
|
||||||
|
+crates/lore-tui/src/sync_delta_ledger.rs # In-memory per-run exact changed/new IDs (issues/MRs/discussions)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Adaptive render governor (runtime performance safety)
|
||||||
|
Why this improves the plan: capability detection is static; you also need dynamic adaptation when frame time/backpressure worsens (SSH, tmux nesting, huge logs).
|
||||||
|
Tradeoff: visual richness may step down automatically under load.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 3.4.1 Capability-Adaptive Rendering
|
||||||
|
+#### 3.4.2 Adaptive Render Governor
|
||||||
|
+Runtime monitors frame time and stream pressure:
|
||||||
|
+- if frame p95 > 40ms or sync drops spike, switch to lighter profile:
|
||||||
|
+ plain markdown, reduced tree guides, slower spinner tick, less frequent repaint.
|
||||||
|
+- when stable for N seconds, restore previous profile.
|
||||||
|
+CLI override:
|
||||||
|
+`lore tui --render-profile=auto|quality|balanced|speed`
|
||||||
|
|
||||||
|
@@ 9.3 Phase 0 — Toolchain Gate
|
||||||
|
+27. Frame-time governor validated: under induced load, UI remains responsive and input latency
|
||||||
|
+stays within p95 < 75ms while auto-downgrading render profile.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. First-run/data-not-ready screen (not an init wizard)
|
||||||
|
Why this improves the plan: empty DB or missing indexes will otherwise feel broken. A dedicated read-only readiness screen improves first impression and self-recovery.
|
||||||
|
Tradeoff: one extra lightweight screen/state.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.3 Core Types (Screen enum)
|
||||||
|
Sync,
|
||||||
|
Stats,
|
||||||
|
Doctor,
|
||||||
|
+ Bootstrap,
|
||||||
|
|
||||||
|
@@ 5.11 Doctor / Stats (Info Screens)
|
||||||
|
+### 5.12 Bootstrap (Data Readiness)
|
||||||
|
+Shown when no synced projects/documents are present or required indexes are missing.
|
||||||
|
+Displays concise readiness checks and exact CLI commands to recover:
|
||||||
|
+`lore sync`, `lore migrate`, `lore --robot doctor`.
|
||||||
|
+Read-only; no auto-execution.
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Global project scope pinning across screens
|
||||||
|
Why this improves the plan: users repeatedly apply the same project filter across dashboard/list/search/timeline/who. Add a global scope pin to reduce repetitive filtering and speed triage.
|
||||||
|
Tradeoff: must show clear “scope active” indicator to avoid confusion.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.1 Module Structure
|
||||||
|
+ scope.rs # Global project scope context (all-projects or pinned project set)
|
||||||
|
|
||||||
|
@@ 8.1 Global (Available Everywhere)
|
||||||
|
+| `P` | Open project scope picker / toggle global scope pin |
|
||||||
|
|
||||||
|
@@ 4.10 State Module — Complete
|
||||||
|
+pub global_scope: ScopeContext, // Applies to dashboard/list/search/timeline/who queries
|
||||||
|
|
||||||
|
@@ 10.11 Action Module — Query Bridge
|
||||||
|
- pub fn fetch_issues(conn: &Connection, filter: &IssueFilter) -> Result<Vec<IssueListRow>, LoreError>
|
||||||
|
+ pub fn fetch_issues(conn: &Connection, scope: &ScopeContext, filter: &IssueFilter) -> Result<Vec<IssueListRow>, LoreError>
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Concurrency correctness tests for pagination and cancellation races
|
||||||
|
Why this improves the plan: current reliability tests are good, but missing a direct test for duplicate/skip behavior under concurrent sync writes while paginating.
|
||||||
|
Tradeoff: additional integration test complexity.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 9.2 Phases (Phase 5.5 — Reliability Test Pack)
|
||||||
|
+ Concurrent pagination/write race tests :p55j, after p55h, 1d
|
||||||
|
+ Query deadline cancellation race tests :p55k, after p55j, 0.5d
|
||||||
|
|
||||||
|
@@ 9.3 Phase 0 — Toolchain Gate
|
||||||
|
+28. Concurrent pagination/write test proves no duplicates/skips within a pinned browse snapshot.
|
||||||
|
+29. Cancellation race test proves no cross-task interrupt bleed and no stuck loading state.
|
||||||
|
```
|
||||||
|
|
||||||
|
9. URL opening policy v2: allowlisted GitLab entity paths
|
||||||
|
Why this improves the plan: host validation is necessary but not always sufficient. Restrict default browser opens to known GitLab entity paths and require confirmation for unusual paths on same host.
|
||||||
|
Tradeoff: occasional extra prompt for uncommon but valid URLs.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 3.1 Risk Matrix
|
||||||
|
-| Malicious URL in entity data opened in browser | Medium | Low | URL host validated against configured GitLab instance before `open`/`xdg-open` |
|
||||||
|
+| Malicious URL in entity data opened in browser | Medium | Low | Validate scheme+host+port and path pattern allowlist (`/-/issues/`, `/-/merge_requests/`, project issue/MR routes). Unknown same-host paths require explicit confirm modal. |
|
||||||
|
|
||||||
|
@@ 10.4.1 Terminal Safety — Untrusted Text Sanitization
|
||||||
|
- pub fn is_safe_url(url: &str, allowed_origins: &[AllowedOrigin]) -> bool
|
||||||
|
+ pub fn classify_safe_url(url: &str, policy: &UrlPolicy) -> UrlSafety
|
||||||
|
+ // UrlSafety::{AllowedEntityPath, AllowedButUnrecognizedPath, Blocked}
|
||||||
|
```
|
||||||
|
|
||||||
|
These 9 changes are additive, avoid previously rejected ideas, and materially improve determinism, responsiveness, post-sync usefulness, and safety without forcing a big architecture reset.
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
plan: true
|
plan: true
|
||||||
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
||||||
status: iterating
|
status: iterating
|
||||||
iteration: 9
|
iteration: 11
|
||||||
target_iterations: 10
|
target_iterations: 10
|
||||||
beads_revision: 0
|
beads_revision: 0
|
||||||
related_plans: []
|
related_plans: []
|
||||||
created: 2026-02-11
|
created: 2026-02-11
|
||||||
updated: 2026-02-11
|
updated: 2026-02-12
|
||||||
---
|
---
|
||||||
|
|
||||||
# Gitlore TUI — Product Requirements Document
|
# Gitlore TUI — Product Requirements Document
|
||||||
@@ -135,7 +135,7 @@ We are making a deliberate bet that FrankenTUI's technical superiority justifies
|
|||||||
| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. |
|
| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. |
|
||||||
| Hard-to-reproduce input race bugs | Medium | Medium | Crash context ring buffer includes last 2000 normalized events + current screen + in-flight task keys/generations + build version + DB fingerprint for post-mortem replay |
|
| Hard-to-reproduce input race bugs | Medium | Medium | Crash context ring buffer includes last 2000 normalized events + current screen + in-flight task keys/generations + build version + DB fingerprint for post-mortem replay |
|
||||||
| Interrupted sync loses partial progress | Medium | Medium | Per-project fault isolation; failed lanes marked degraded while others continue. Resumable checkpoints planned for post-v1 (requires `sync_checkpoints` table). |
|
| Interrupted sync loses partial progress | Medium | Medium | Per-project fault isolation; failed lanes marked degraded while others continue. Resumable checkpoints planned for post-v1 (requires `sync_checkpoints` table). |
|
||||||
| Malicious URL in entity data opened in browser | Medium | Low | URL host validated against configured GitLab instance before `open`/`xdg-open` |
|
| Malicious URL in entity data opened in browser | Medium | Low | Validate scheme+host+port AND path pattern allowlist (`/-/issues/`, `/-/merge_requests/`, project issue/MR routes) before `open`/`xdg-open`. Unknown same-host paths require explicit confirm modal. |
|
||||||
| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars + C1 controls (U+0080..U+009F) + bidi overrides + directional marks (LRM/RLM/ALM) via `sanitize_for_terminal()` before render; origin-normalized URL validation before open; disable raw HTML in markdown rendering |
|
| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars + C1 controls (U+0080..U+009F) + bidi overrides + directional marks (LRM/RLM/ALM) via `sanitize_for_terminal()` before render; origin-normalized URL validation before open; disable raw HTML in markdown rendering |
|
||||||
|
|
||||||
### 3.2 Nightly Rust Strategy
|
### 3.2 Nightly Rust Strategy
|
||||||
@@ -288,7 +288,9 @@ crates/lore-tui/src/
|
|||||||
safety.rs # sanitize_for_terminal(), safe_url_policy()
|
safety.rs # sanitize_for_terminal(), safe_url_policy()
|
||||||
redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting to disk
|
redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting to disk
|
||||||
session.rs # Versioned session state persistence + corruption quarantine
|
session.rs # Versioned session state persistence + corruption quarantine
|
||||||
entity_cache.rs # Bounded LRU cache for detail payloads (IssueDetail, MrDetail). Keyed by EntityKey. Invalidated on sync completion. Enables near-instant reopen during Enter/Esc drill-in/out workflows without re-querying.
|
scope.rs # Global project scope context: all-projects or pinned project set. Applied to dashboard/list/search/timeline/who queries. Persisted in session state.
|
||||||
|
entity_cache.rs # Bounded LRU cache for detail payloads (IssueDetail, MrDetail). Keyed by EntityKey. Selective invalidation by changed EntityKey set on sync completion (not blanket invalidate_all). Optional post-sync prewarm of top changed entities for immediate triage. Enables near-instant reopen during Enter/Esc drill-in/out workflows without re-querying.
|
||||||
|
render_cache.rs # Width/theme/content-hash keyed cache for expensive render artifacts (markdown → styled text, discussion tree shaping). Invalidation triggers: content hash change, terminal width change, theme change. Prevents per-frame recomputation of markdown parsing and tree layout.
|
||||||
crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task snapshot for crash diagnostics. Captured by panic hook for post-mortem debugging.
|
crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task snapshot for crash diagnostics. Captured by panic hook for post-mortem debugging.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -359,20 +361,24 @@ pub enum Msg {
|
|||||||
CommandPaletteSelect(usize),
|
CommandPaletteSelect(usize),
|
||||||
|
|
||||||
// Issue list
|
// Issue list
|
||||||
IssueListLoaded(Vec<IssueRow>),
|
/// Generation-guarded: stale results from superseded filter/nav are dropped.
|
||||||
|
IssueListLoaded { generation: u64, rows: Vec<IssueRow> },
|
||||||
IssueListFilterChanged(IssueFilter),
|
IssueListFilterChanged(IssueFilter),
|
||||||
IssueListSortChanged(SortField, SortOrder),
|
IssueListSortChanged(SortField, SortOrder),
|
||||||
IssueSelected(EntityKey),
|
IssueSelected(EntityKey),
|
||||||
|
|
||||||
// MR list
|
// MR list
|
||||||
MrListLoaded(Vec<MrRow>),
|
/// Generation-guarded: stale results from superseded filter/nav are dropped.
|
||||||
|
MrListLoaded { generation: u64, rows: Vec<MrRow> },
|
||||||
MrListFilterChanged(MrFilter),
|
MrListFilterChanged(MrFilter),
|
||||||
MrSelected(EntityKey),
|
MrSelected(EntityKey),
|
||||||
|
|
||||||
// Detail views
|
// Detail views
|
||||||
IssueDetailLoaded { key: EntityKey, detail: IssueDetail },
|
/// Generation-guarded: prevents stale detail overwrites after rapid navigation.
|
||||||
MrDetailLoaded { key: EntityKey, detail: MrDetail },
|
IssueDetailLoaded { generation: u64, key: EntityKey, detail: IssueDetail },
|
||||||
DiscussionsLoaded(Vec<Discussion>),
|
/// Generation-guarded: prevents stale detail overwrites after rapid navigation.
|
||||||
|
MrDetailLoaded { generation: u64, key: EntityKey, detail: MrDetail },
|
||||||
|
DiscussionsLoaded { generation: u64, discussions: Vec<Discussion> },
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
SearchQueryChanged(String),
|
SearchQueryChanged(String),
|
||||||
@@ -395,6 +401,9 @@ pub enum Msg {
|
|||||||
// Sync
|
// Sync
|
||||||
SyncStarted,
|
SyncStarted,
|
||||||
SyncProgress(ProgressEvent),
|
SyncProgress(ProgressEvent),
|
||||||
|
/// Coalesced batch of progress events (one per lane key).
|
||||||
|
/// Reduces render pressure by batching at <=30Hz per lane.
|
||||||
|
SyncProgressBatch(Vec<ProgressEvent>),
|
||||||
SyncLogLine(String),
|
SyncLogLine(String),
|
||||||
SyncBackpressureDrop,
|
SyncBackpressureDrop,
|
||||||
SyncCompleted(SyncResult),
|
SyncCompleted(SyncResult),
|
||||||
@@ -454,6 +463,7 @@ pub enum Screen {
|
|||||||
Sync,
|
Sync,
|
||||||
Stats,
|
Stats,
|
||||||
Doctor,
|
Doctor,
|
||||||
|
Bootstrap,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composite key for entity identity across multi-project datasets.
|
/// Composite key for entity identity across multi-project datasets.
|
||||||
@@ -553,7 +563,7 @@ impl Default for InputMode {
|
|||||||
// crates/lore-tui/src/app.rs
|
// crates/lore-tui/src/app.rs
|
||||||
|
|
||||||
use ftui_runtime::program::{Model, Cmd, TaskSpec};
|
use ftui_runtime::program::{Model, Cmd, TaskSpec};
|
||||||
use ftui_runtime::subscription::{Subscription, Every};
|
use ftui_runtime::subscription::{Subscription, Every, After};
|
||||||
use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers};
|
use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers};
|
||||||
use ftui_render::frame::Frame;
|
use ftui_render::frame::Frame;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -626,6 +636,20 @@ pub struct DbManager {
|
|||||||
next_reader: AtomicUsize,
|
next_reader: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A task-scoped reader lease that owns an interrupt handle for safe cancellation.
|
||||||
|
/// Unlike interrupting a shared pooled connection (which can cancel unrelated work),
|
||||||
|
/// each dispatched query receives its own ReaderLease. The InterruptHandle stored in
|
||||||
|
/// TaskHandle targets only this lease's connection, preventing cross-task cancellation bleed.
|
||||||
|
pub struct ReaderLease<'a> {
|
||||||
|
conn: std::sync::MutexGuard<'a, Connection>,
|
||||||
|
/// Owned interrupt handle — safe to fire without affecting other tasks.
|
||||||
|
pub interrupt: rusqlite::InterruptHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ReaderLease<'a> {
|
||||||
|
pub fn conn(&self) -> &Connection { &self.conn }
|
||||||
|
}
|
||||||
|
|
||||||
impl DbManager {
|
impl DbManager {
|
||||||
pub fn new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError> {
|
pub fn new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError> {
|
||||||
let mut readers = Vec::with_capacity(reader_count);
|
let mut readers = Vec::with_capacity(reader_count);
|
||||||
@@ -663,6 +687,19 @@ impl DbManager {
|
|||||||
.map_err(|e| LoreError::Internal(format!("writer lock poisoned: {e}")))?;
|
.map_err(|e| LoreError::Internal(format!("writer lock poisoned: {e}")))?;
|
||||||
f(&conn)
|
f(&conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lease a reader connection with a task-owned interrupt handle.
|
||||||
|
/// The returned `ReaderLease` holds the mutex guard and provides
|
||||||
|
/// an `InterruptHandle` that can be stored in `TaskHandle` for
|
||||||
|
/// safe per-task cancellation. This prevents cross-task interrupt bleed
|
||||||
|
/// that would occur with shared-connection `sqlite3_interrupt()`.
|
||||||
|
pub fn lease_reader(&self) -> Result<ReaderLease<'_>, LoreError> {
|
||||||
|
let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % self.readers.len();
|
||||||
|
let conn = self.readers[idx].lock()
|
||||||
|
.map_err(|e| LoreError::Internal(format!("reader lock poisoned: {e}")))?;
|
||||||
|
let interrupt = conn.get_interrupt_handle();
|
||||||
|
Ok(ReaderLease { conn, interrupt })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoreApp {
|
impl LoreApp {
|
||||||
@@ -786,9 +823,11 @@ impl LoreApp {
|
|||||||
}),
|
}),
|
||||||
Screen::IssueList => {
|
Screen::IssueList => {
|
||||||
let filter = self.state.issue_list.current_filter();
|
let filter = self.state.issue_list.current_filter();
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
||||||
Ok(result) => Msg::IssueListLoaded(result),
|
Ok(rows) => Msg::IssueListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -797,21 +836,26 @@ impl LoreApp {
|
|||||||
// Check entity cache first — enables near-instant reopen
|
// Check entity cache first — enables near-instant reopen
|
||||||
// during Enter/Esc drill-in/out workflows.
|
// during Enter/Esc drill-in/out workflows.
|
||||||
if let Some(cached) = self.entity_cache.get_issue(key) {
|
if let Some(cached) = self.entity_cache.get_issue(key) {
|
||||||
return Cmd::msg(Msg::IssueDetailLoaded { key: key.clone(), detail: cached.clone() });
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueDetail(key.clone())));
|
||||||
|
return Cmd::msg(Msg::IssueDetailLoaded { generation: handle.generation, key: key.clone(), detail: cached.clone() });
|
||||||
}
|
}
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueDetail(key.clone())));
|
||||||
|
let generation = handle.generation;
|
||||||
let key = key.clone();
|
let key = key.clone();
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_issue_detail(conn, &key)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_issue_detail(conn, &key)) {
|
||||||
Ok(detail) => Msg::IssueDetailLoaded { key, detail },
|
Ok(detail) => Msg::IssueDetailLoaded { generation, key, detail },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Screen::MrList => {
|
Screen::MrList => {
|
||||||
let filter = self.state.mr_list.current_filter();
|
let filter = self.state.mr_list.current_filter();
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
||||||
Ok(result) => Msg::MrListLoaded(result),
|
Ok(rows) => Msg::MrListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -819,12 +863,15 @@ impl LoreApp {
|
|||||||
Screen::MrDetail(key) => {
|
Screen::MrDetail(key) => {
|
||||||
// Check entity cache first
|
// Check entity cache first
|
||||||
if let Some(cached) = self.entity_cache.get_mr(key) {
|
if let Some(cached) = self.entity_cache.get_mr(key) {
|
||||||
return Cmd::msg(Msg::MrDetailLoaded { key: key.clone(), detail: cached.clone() });
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrDetail(key.clone())));
|
||||||
|
return Cmd::msg(Msg::MrDetailLoaded { generation: handle.generation, key: key.clone(), detail: cached.clone() });
|
||||||
}
|
}
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrDetail(key.clone())));
|
||||||
|
let generation = handle.generation;
|
||||||
let key = key.clone();
|
let key = key.clone();
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_mr_detail(conn, &key)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_mr_detail(conn, &key)) {
|
||||||
Ok(detail) => Msg::MrDetailLoaded { key, detail },
|
Ok(detail) => Msg::MrDetailLoaded { generation, key, detail },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -895,9 +942,11 @@ impl LoreApp {
|
|||||||
Screen::IssueList => {
|
Screen::IssueList => {
|
||||||
let filter = self.state.issue_list.current_filter();
|
let filter = self.state.issue_list.current_filter();
|
||||||
let db = Arc::clone(&self.db);
|
let db = Arc::clone(&self.db);
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::FilterRequery(Screen::IssueList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
||||||
Ok(result) => Msg::IssueListLoaded(result),
|
Ok(rows) => Msg::IssueListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -905,9 +954,11 @@ impl LoreApp {
|
|||||||
Screen::MrList => {
|
Screen::MrList => {
|
||||||
let filter = self.state.mr_list.current_filter();
|
let filter = self.state.mr_list.current_filter();
|
||||||
let db = Arc::clone(&self.db);
|
let db = Arc::clone(&self.db);
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::FilterRequery(Screen::MrList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
||||||
Ok(result) => Msg::MrListLoaded(result),
|
Ok(rows) => Msg::MrListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -961,15 +1012,18 @@ impl LoreApp {
|
|||||||
if cancel_token.load(std::sync::atomic::Ordering::Relaxed) {
|
if cancel_token.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
return; // Early exit — orchestrator handles partial state
|
return; // Early exit — orchestrator handles partial state
|
||||||
}
|
}
|
||||||
// Track queue depth for stream stats
|
// Coalesce progress events by lane key at <=30Hz to reduce
|
||||||
let current_depth = 2048 - tx.try_send(Msg::SyncProgress(event.clone()))
|
// render pressure. Each lane (project x resource_type) keeps
|
||||||
.err().map_or(0, |_| 1);
|
// only its latest progress snapshot. The coalescer flushes
|
||||||
max_queue_depth = max_queue_depth.max(current_depth);
|
// a batch when 33ms have elapsed since last flush.
|
||||||
if tx.try_send(Msg::SyncProgress(event.clone())).is_err() {
|
coalescer.update(event.clone());
|
||||||
// Channel full — drop this progress update rather than
|
if let Some(batch) = coalescer.flush_ready() {
|
||||||
// blocking the sync thread. Track for stats.
|
if tx.try_send(Msg::SyncProgressBatch(batch)).is_err() {
|
||||||
dropped_count += 1;
|
// Channel full — drop this batch rather than
|
||||||
let _ = tx.try_send(Msg::SyncBackpressureDrop);
|
// blocking the sync thread. Track for stats.
|
||||||
|
dropped_count += 1;
|
||||||
|
let _ = tx.try_send(Msg::SyncBackpressureDrop);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}")));
|
let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}")));
|
||||||
},
|
},
|
||||||
@@ -1143,23 +1197,35 @@ impl Model for LoreApp {
|
|||||||
self.state.dashboard.update(data);
|
self.state.dashboard.update(data);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::IssueListLoaded(result) => {
|
Msg::IssueListLoaded { generation, rows } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation) {
|
||||||
|
return Cmd::none(); // Stale — superseded by newer nav/filter
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.state.issue_list.set_result(result);
|
self.state.issue_list.set_result(rows);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::IssueDetailLoaded { key, detail } => {
|
Msg::IssueDetailLoaded { generation, key, detail } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueDetail(key.clone())), generation) {
|
||||||
|
return Cmd::none(); // Stale — user navigated away
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.entity_cache.put_issue(key, detail.clone());
|
self.entity_cache.put_issue(key, detail.clone());
|
||||||
self.state.issue_detail.set(detail);
|
self.state.issue_detail.set(detail);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::MrListLoaded(result) => {
|
Msg::MrListLoaded { generation, rows } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::MrList), generation) {
|
||||||
|
return Cmd::none(); // Stale — superseded by newer nav/filter
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.state.mr_list.set_result(result);
|
self.state.mr_list.set_result(rows);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::MrDetailLoaded { key, detail } => {
|
Msg::MrDetailLoaded { generation, key, detail } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::MrDetail(key.clone())), generation) {
|
||||||
|
return Cmd::none(); // Stale — user navigated away
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.entity_cache.put_mr(key, detail.clone());
|
self.entity_cache.put_mr(key, detail.clone());
|
||||||
self.state.mr_detail.set(detail);
|
self.state.mr_detail.set(detail);
|
||||||
@@ -1219,6 +1285,12 @@ impl Model for LoreApp {
|
|||||||
self.state.sync.update_progress(event);
|
self.state.sync.update_progress(event);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
|
Msg::SyncProgressBatch(events) => {
|
||||||
|
for event in events {
|
||||||
|
self.state.sync.update_progress(event);
|
||||||
|
}
|
||||||
|
Cmd::none()
|
||||||
|
}
|
||||||
Msg::SyncLogLine(line) => {
|
Msg::SyncLogLine(line) => {
|
||||||
self.state.sync.push_log(line);
|
self.state.sync.push_log(line);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
@@ -1234,10 +1306,15 @@ impl Model for LoreApp {
|
|||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::SyncCompleted(result) => {
|
Msg::SyncCompleted(result) => {
|
||||||
self.state.sync.complete(result);
|
self.state.sync.complete(&result);
|
||||||
// Invalidate entity cache — synced data may have changed.
|
// Selective invalidation: evict only changed entities from sync delta.
|
||||||
self.entity_cache.invalidate_all();
|
self.entity_cache.invalidate_keys(&result.changed_entity_keys);
|
||||||
Cmd::none()
|
// Prewarm top N changed/new entities for immediate post-sync triage.
|
||||||
|
// This is lazy — enqueues Cmd::task fetches, doesn't block the event loop.
|
||||||
|
let prewarm_cmds = self.enqueue_cache_prewarm(&result.changed_entity_keys);
|
||||||
|
// Notify list screens that new data is available (snapshot fence refresh badge).
|
||||||
|
self.state.notify_data_changed();
|
||||||
|
prewarm_cmds
|
||||||
}
|
}
|
||||||
Msg::SyncFailed(err) => {
|
Msg::SyncFailed(err) => {
|
||||||
self.state.sync.fail(err);
|
self.state.sync.fail(err);
|
||||||
@@ -1416,21 +1493,23 @@ impl Model for LoreApp {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go-prefix timeout enforcement: tick even when nothing is loading.
|
// Go-prefix timeout: one-shot After(500ms) tied to the prefix start.
|
||||||
// Without this, GoPrefix mode can get "stuck" when idle (no other
|
// Uses After (one-shot) instead of Every (periodic) — the prefix
|
||||||
// events to drive the Tick that checks the 500ms timeout).
|
// either completes with a valid key or times out exactly once.
|
||||||
if matches!(self.input_mode, InputMode::GoPrefix { .. }) {
|
if matches!(self.input_mode, InputMode::GoPrefix { .. }) {
|
||||||
subs.push(Box::new(
|
subs.push(Box::new(
|
||||||
Every::with_id(2, Duration::from_millis(50), || Msg::Tick)
|
After::with_id(2, Duration::from_millis(500), || Msg::Tick)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search debounce timer: fires SearchDebounceFired after 200ms.
|
// Search debounce timer: one-shot fires SearchDebounceFired after 200ms.
|
||||||
// Only active when a debounce is pending (armed by keystroke).
|
// Only active when a debounce is pending (armed by keystroke).
|
||||||
|
// Uses After (one-shot) instead of Every (periodic) to avoid repeated
|
||||||
|
// firings from a periodic timer — one debounce = one fire.
|
||||||
if self.state.search.debounce_pending() {
|
if self.state.search.debounce_pending() {
|
||||||
let generation = self.state.search.debounce_generation();
|
let generation = self.state.search.debounce_generation();
|
||||||
subs.push(Box::new(
|
subs.push(Box::new(
|
||||||
Every::with_id(3, Duration::from_millis(200), move || {
|
After::with_id(3, Duration::from_millis(200), move || {
|
||||||
Msg::SearchDebounceFired { generation }
|
Msg::SearchDebounceFired { generation }
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
@@ -1485,7 +1564,7 @@ pub fn with_read_snapshot<T>(
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Query interruption:** Long-running queries register interrupt checks tied to `CancelToken` to avoid >1s uninterruptible stalls during rapid navigation/filtering. When the user navigates away from a detail screen before queries complete, the cancel token fires `sqlite3_interrupt()` on the connection.
|
**Query interruption:** Long-running queries use task-owned `ReaderLease` interrupt handles (from `DbManager::lease_reader()`) to avoid >1s uninterruptible stalls during rapid navigation/filtering. When the user navigates away from a detail screen before queries complete, the `TaskHandle`'s owned `InterruptHandle` fires `sqlite3_interrupt()` on that specific leased connection — never on a shared pool connection. This prevents cross-task cancellation bleed where interrupting one query accidentally cancels an unrelated query on the same pooled connection.
|
||||||
|
|
||||||
#### 4.5.1 Task Supervisor (Dedup + Cancellation + Priority)
|
#### 4.5.1 Task Supervisor (Dedup + Cancellation + Priority)
|
||||||
|
|
||||||
@@ -1549,6 +1628,10 @@ pub struct TaskHandle {
|
|||||||
pub key: TaskKey,
|
pub key: TaskKey,
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
pub cancel: Arc<CancelToken>,
|
pub cancel: Arc<CancelToken>,
|
||||||
|
/// Per-task SQLite interrupt handle. When set, cancellation fires
|
||||||
|
/// this handle instead of interrupting shared pool connections.
|
||||||
|
/// Prevents cross-task cancellation bleed.
|
||||||
|
pub interrupt: Option<rusqlite::InterruptHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The TaskSupervisor manages active tasks, deduplicates by key, and tracks
|
/// The TaskSupervisor manages active tasks, deduplicates by key, and tracks
|
||||||
@@ -1756,6 +1839,11 @@ pub struct NavigationStack {
|
|||||||
/// This mirrors vim's jump list behavior.
|
/// This mirrors vim's jump list behavior.
|
||||||
jump_list: Vec<Screen>,
|
jump_list: Vec<Screen>,
|
||||||
jump_index: usize,
|
jump_index: usize,
|
||||||
|
/// Browse snapshot token: each list/search screen carries a per-screen
|
||||||
|
/// `BrowseSnapshot` that preserves stable ordering until explicit refresh
|
||||||
|
/// or screen re-entry. This works with the snapshot fence to ensure
|
||||||
|
/// deterministic pagination during concurrent sync writes.
|
||||||
|
browse_snapshots: HashMap<ScreenKind, BrowseSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NavigationStack {
|
impl NavigationStack {
|
||||||
@@ -1979,9 +2067,21 @@ Insights are computed from local data during dashboard load. Each insight row is
|
|||||||
**Data source:** `lore issues` query against SQLite
|
**Data source:** `lore issues` query against SQLite
|
||||||
**Columns:** Configurable — iid, title, state, author, labels, milestone, updated_at
|
**Columns:** Configurable — iid, title, state, author, labels, milestone, updated_at
|
||||||
**Sorting:** Click column header or Tab to cycle (iid, updated, created)
|
**Sorting:** Click column header or Tab to cycle (iid, updated, created)
|
||||||
**Filtering:** Interactive filter bar with field:value syntax
|
**Filtering:** Interactive filter bar with typed DSL parser. Grammar (v1):
|
||||||
|
- `term := [ "-" ] (field ":" value | quoted_text | bare_text)`
|
||||||
|
- `value := quoted | unquoted`
|
||||||
|
- Examples: `state:opened label:"P1 blocker" -author:bot since:14d`
|
||||||
|
- Negation prefix (`-`) excludes matches for that term
|
||||||
|
- Quoted values allow spaces in filter values
|
||||||
|
- Parser surfaces inline diagnostics with cursor position for parse errors — never silently drops unknown fields
|
||||||
**Pagination:** Windowed keyset pagination with explicit cursor state. The list state maintains `window` (current visible rows), `next_cursor` / `prev_cursor` (keyset boundary values for forward/back navigation), `prefetching` flag (background fetch of next window in progress), and a fixed `window_size` (default 200 rows). First paint uses current window only; no full-result materialization. Virtual scrolling within the window for smooth UX. When the user scrolls past ~80% of the window, the next window is prefetched in the background.
|
**Pagination:** Windowed keyset pagination with explicit cursor state. The list state maintains `window` (current visible rows), `next_cursor` / `prev_cursor` (keyset boundary values for forward/back navigation), `prefetching` flag (background fetch of next window in progress), and a fixed `window_size` (default 200 rows). First paint uses current window only; no full-result materialization. Virtual scrolling within the window for smooth UX. When the user scrolls past ~80% of the window, the next window is prefetched in the background.
|
||||||
|
|
||||||
|
**Snapshot fence:** On list entry, capture `snapshot_upper_updated_at` (current max `updated_at` in the result set) and pin all list-page queries to `updated_at <= snapshot_upper_updated_at`. This guarantees no duplicate or skipped rows during scrolling even if sync writes occur concurrently. A "new data available" badge appears when a newer sync completes; `r` refreshes the fence and re-queries from the top.
|
||||||
|
|
||||||
|
**Quick Peek (`Space`):** Toggle a right-side preview pane showing the selected item's metadata, first discussion snippet, and cross-references without entering the full detail view. This enables rapid triage scanning — the user can evaluate issues at a glance without the Enter/Esc cycle. The peek pane uses the same progressive hydration as detail views (metadata first, discussions lazy). The pane width adapts to terminal breakpoints (hidden at Xs/Sm, 40% width at Md+).
|
||||||
|
|
||||||
|
**Cursor determinism:** Keyset pagination uses deterministic tuple ordering: `ORDER BY <primary_sort>, project_id, iid`. The cursor struct includes the current `sort_field`, `sort_order`, `project_id` (tie-breaker for multi-project datasets where rows share timestamps), and a `filter_hash: u64` (hash of the active filter state). On cursor resume, the cursor is rejected if `filter_hash` or sort tuple mismatches the current query — this prevents stale cursors from producing duplicate/skipped rows after the user changes sort mode or filters mid-browse.
|
||||||
|
|
||||||
### 5.3 Issue Detail
|
### 5.3 Issue Detail
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -2052,7 +2152,9 @@ Identical structure to Issue List with MR-specific columns:
|
|||||||
| Author | MR author |
|
| Author | MR author |
|
||||||
| Updated | Relative time |
|
| Updated | Relative time |
|
||||||
|
|
||||||
**Pagination:** Same windowed keyset pagination strategy as Issue List (window=200, background prefetch).
|
**Pagination:** Same windowed keyset pagination strategy as Issue List (window=200, background prefetch, deterministic cursor with `project_id` tie-breaker and `filter_hash` invalidation). Same snapshot fence (`updated_at <= snapshot_upper_updated_at`) for deterministic cross-page traversal under concurrent sync writes.
|
||||||
|
|
||||||
|
**Quick Peek (`Space`):** Same as Issue List — toggle right preview pane showing MR metadata, first discussion snippet, and cross-references for rapid triage without entering detail view.
|
||||||
|
|
||||||
**Additional filters:** `--draft`, `--no-draft`, `--target-branch`, `--source-branch`, `--reviewer`
|
**Additional filters:** `--draft`, `--no-draft`, `--target-branch`, `--source-branch`, `--reviewer`
|
||||||
|
|
||||||
@@ -2294,8 +2396,8 @@ The Sync screen has two modes: **running** (progress + log) and **summary** (pos
|
|||||||
|
|
||||||
**Summary mode:**
|
**Summary mode:**
|
||||||
- Shows delta counts (new, updated) for each entity type
|
- Shows delta counts (new, updated) for each entity type
|
||||||
- `i` navigates to Issue List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
- `i` navigates to Issue List filtered by exact issue IDs changed in this sync run (from in-memory `SyncDeltaLedger`). Falls back to timestamp filter via `sync_status.last_completed_at` only if run delta is not available (e.g., after app restart).
|
||||||
- `m` navigates to MR List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
- `m` navigates to MR List filtered by exact MR IDs changed in this sync run (from in-memory `SyncDeltaLedger`). Falls back to timestamp filter only if run delta is not available.
|
||||||
- `r` restarts sync
|
- `r` restarts sync
|
||||||
|
|
||||||
### 5.10 Command Palette (Overlay)
|
### 5.10 Command Palette (Overlay)
|
||||||
@@ -2349,6 +2451,21 @@ The Sync screen has two modes: **running** (progress + log) and **summary** (pos
|
|||||||
- Does NOT auto-execute commands — the user always runs them manually for safety
|
- Does NOT auto-execute commands — the user always runs them manually for safety
|
||||||
- Scrollable with j/k, Esc to go back
|
- Scrollable with j/k, Esc to go back
|
||||||
|
|
||||||
|
### 5.12 Bootstrap (Data Readiness)
|
||||||
|
|
||||||
|
Shown automatically when the TUI detects no synced projects/documents or required indexes are missing. This is a read-only screen — it never auto-executes commands.
|
||||||
|
|
||||||
|
Displays concise readiness checks with pass/fail indicators:
|
||||||
|
- Synced projects present?
|
||||||
|
- Issues/MRs populated?
|
||||||
|
- FTS index built?
|
||||||
|
- Embedding index built? (optional — warns but doesn't block)
|
||||||
|
- Required migration version met?
|
||||||
|
|
||||||
|
For each failing check, shows the exact CLI command to recover (e.g., `lore sync`, `lore migrate`, `lore --robot doctor`). The user exits the TUI and runs the commands manually.
|
||||||
|
|
||||||
|
This prevents the "blank screen" first-run experience where a user launches `lore tui` before syncing data and sees an empty dashboard with no indication of what to do next.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. User Flows
|
## 6. User Flows
|
||||||
@@ -2483,8 +2600,8 @@ graph TD
|
|||||||
style F fill:#51cf66,stroke:#333,color:#fff
|
style F fill:#51cf66,stroke:#333,color:#fff
|
||||||
```
|
```
|
||||||
|
|
||||||
**Keystrokes:** `i` → `j/k` to scan → `Enter` to peek → `Esc` to return → continue scanning
|
**Keystrokes:** `i` → `j/k` to scan → `Space` to Quick Peek (or `Enter` for full detail) → `Esc` to return → continue scanning
|
||||||
**State preservation:** After pressing Esc from Issue Detail, the cursor returns to exactly the same row in the list. Filter state and scroll offset are preserved. This tight Enter/Esc loop is the most common daily workflow.
|
**State preservation:** After pressing Esc from Issue Detail, the cursor returns to exactly the same row in the list. Filter state and scroll offset are preserved. This tight Enter/Esc loop is the most common daily workflow. Quick Peek (`Space`) makes triage even faster — preview metadata and first discussion snippet without leaving the list.
|
||||||
|
|
||||||
### 6.8 Flow: "Jump between screens without returning to Dashboard"
|
### 6.8 Flow: "Jump between screens without returning to Dashboard"
|
||||||
|
|
||||||
@@ -2591,6 +2708,7 @@ graph TD
|
|||||||
| `Ctrl+O` | Jump backward in jump list (entity hops) |
|
| `Ctrl+O` | Jump backward in jump list (entity hops) |
|
||||||
| `Alt+o` | Jump forward in jump list (entity hops) |
|
| `Alt+o` | Jump forward in jump list (entity hops) |
|
||||||
| `Ctrl+R` | Reset session state for current screen (clear filters, scroll to top) |
|
| `Ctrl+R` | Reset session state for current screen (clear filters, scroll to top) |
|
||||||
|
| `P` | Open project scope picker / toggle global scope pin. When a scope is pinned, all list/search/timeline/who queries are filtered to that project set. A visible `[scope: project/path]` indicator appears in the status bar. |
|
||||||
| `Ctrl+C` | Quit (force) |
|
| `Ctrl+C` | Quit (force) |
|
||||||
|
|
||||||
### 8.2 List Screens (Issues, MRs, Search Results)
|
### 8.2 List Screens (Issues, MRs, Search Results)
|
||||||
@@ -2600,6 +2718,7 @@ graph TD
|
|||||||
| `j` / `↓` | Move selection down |
|
| `j` / `↓` | Move selection down |
|
||||||
| `k` / `↑` | Move selection up |
|
| `k` / `↑` | Move selection up |
|
||||||
| `Enter` | Open selected item |
|
| `Enter` | Open selected item |
|
||||||
|
| `Space` | Toggle Quick Peek panel for selected row |
|
||||||
| `G` | Jump to bottom |
|
| `G` | Jump to bottom |
|
||||||
| `g` `g` | Jump to top |
|
| `g` `g` | Jump to top |
|
||||||
| `Tab` / `f` | Focus filter bar |
|
| `Tab` / `f` | Focus filter bar |
|
||||||
@@ -2614,7 +2733,7 @@ graph TD
|
|||||||
3. Global shortcuts — `q`, `H`, `?`, `o`, `Ctrl+C`, `Ctrl+P`, `Esc`, `g` prefix
|
3. Global shortcuts — `q`, `H`, `?`, `o`, `Ctrl+C`, `Ctrl+P`, `Esc`, `g` prefix
|
||||||
4. Screen-local shortcuts — per-screen key handlers (the table above)
|
4. Screen-local shortcuts — per-screen key handlers (the table above)
|
||||||
|
|
||||||
**Go-prefix timeout:** 500ms from first `g` press, enforced by `InputMode::GoPrefix { started_at }` state checked on each tick via `clock.now_instant()`. If no valid continuation key arrives within 500ms, the prefix cancels and a brief "g--" flash clears from the status bar. The tick subscription compares the injected Clock's current instant against `started_at` — no separate timer task needed. Using `InputMode` instead of ad-hoc boolean flags makes the state machine explicit and deterministic. Feedback is immediate — the status bar shows "g--" within the same frame as the keypress.
|
**Go-prefix timeout:** 500ms from first `g` press, enforced by a one-shot `After(500ms)` subscription tied to the prefix generation. If no valid continuation key arrives within 500ms, the timer fires a single `Msg::Tick` which checks `InputMode::GoPrefix { started_at }` via `clock.now_instant()` and cancels the prefix. A brief "g--" flash clears from the status bar. Using `After` (one-shot) instead of `Every` (periodic) avoids unnecessary repeated ticks. Using `InputMode` instead of ad-hoc boolean flags makes the state machine explicit and deterministic. Feedback is immediate — the status bar shows "g--" within the same frame as the keypress.
|
||||||
|
|
||||||
**Terminal keybinding safety notes:**
|
**Terminal keybinding safety notes:**
|
||||||
- `Ctrl+I` is NOT used — it is indistinguishable from `Tab` in most terminals (both send `\x09`). Jump-forward uses `Alt+o` instead.
|
- `Ctrl+I` is NOT used — it is indistinguishable from `Tab` in most terminals (both send `\x09`). Jump-forward uses `Alt+o` instead.
|
||||||
@@ -2783,6 +2902,8 @@ gantt
|
|||||||
Event fuzz tests (key/resize/paste, deterministic seed replay):p55g, after p55e, 1d
|
Event fuzz tests (key/resize/paste, deterministic seed replay):p55g, after p55e, 1d
|
||||||
Deterministic clock/render tests:p55i, after p55g, 0.5d
|
Deterministic clock/render tests:p55i, after p55g, 0.5d
|
||||||
30-minute soak test (no panic/leak):p55h, after p55i, 1d
|
30-minute soak test (no panic/leak):p55h, after p55i, 1d
|
||||||
|
Concurrent pagination/write race tests :p55j, after p55h, 1d
|
||||||
|
Query cancellation race tests :p55k, after p55j, 0.5d
|
||||||
|
|
||||||
section Phase 5.6 — CLI/TUI Parity Pack
|
section Phase 5.6 — CLI/TUI Parity Pack
|
||||||
Dashboard count parity tests :p56a, after p55h, 0.5d
|
Dashboard count parity tests :p56a, after p55h, 0.5d
|
||||||
@@ -2802,7 +2923,7 @@ Ensures the TUI displays the same data as the CLI robot mode, preventing drift b
|
|||||||
|
|
||||||
**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data.
|
**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data.
|
||||||
|
|
||||||
**Total estimated scope:** ~47 implementation days across 9 phases (increased from ~43 to account for Phase 2.5 vertical slice gate, entity cache, crash context ring buffer, timer-based debounce, and expanded success criteria 24-25).
|
**Total estimated scope:** ~51 implementation days across 9 phases (increased from ~49 to account for snapshot fences, sync delta ledger, bootstrap screen, global scope pinning, concurrent pagination/write race tests, and cancellation race tests).
|
||||||
|
|
||||||
### 9.3 Phase 0 — Toolchain Gate
|
### 9.3 Phase 0 — Toolchain Gate
|
||||||
|
|
||||||
@@ -2848,6 +2969,8 @@ This is a hard gate. If Phase 0 fails, we evaluate alternatives before proceedin
|
|||||||
23. Single-instance lock enforced: second TUI launch attempt yields clear error message and non-zero exit.
|
23. Single-instance lock enforced: second TUI launch attempt yields clear error message and non-zero exit.
|
||||||
24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure.
|
24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure.
|
||||||
25. Entity cache provides near-instant reopen for Issue/MR detail views during Enter/Esc drill-in/out workflows; cache invalidated on sync completion.
|
25. Entity cache provides near-instant reopen for Issue/MR detail views during Enter/Esc drill-in/out workflows; cache invalidated on sync completion.
|
||||||
|
26. Concurrent pagination/write race test proves no duplicate or skipped rows within a pinned browse snapshot fence under concurrent sync writes.
|
||||||
|
27. Cancellation race test proves no cross-task interrupt bleed and no stuck loading state after rapid cancel-then-resubmit sequences.
|
||||||
|
|
||||||
**Performance SLO rationale:** Interactive TUI responsiveness requires sub-100ms for list operations and sub-250ms for search. Tiered fixtures catch scaling regressions at different data magnitudes — a query that's fast at 10k rows may degrade at 100k without proper indexing or pagination. Memory ceilings prevent unbounded growth from large in-memory result sets. These targets are validated with synthetic SQLite fixtures during Phase 0 and enforced as CI benchmark gates thereafter. Required indexes are documented and migration-backed before TUI GA.
|
**Performance SLO rationale:** Interactive TUI responsiveness requires sub-100ms for list operations and sub-250ms for search. Tiered fixtures catch scaling regressions at different data magnitudes — a query that's fast at 10k rows may degrade at 100k without proper indexing or pagination. Memory ceilings prevent unbounded growth from large in-memory result sets. These targets are validated with synthetic SQLite fixtures during Phase 0 and enforced as CI benchmark gates thereafter. Required indexes are documented and migration-backed before TUI GA.
|
||||||
|
|
||||||
@@ -2912,7 +3035,12 @@ crates/lore-tui/src/theme.rs # ftui Theme config
|
|||||||
crates/lore-tui/src/action.rs # Query bridge functions (uses lore core)
|
crates/lore-tui/src/action.rs # Query bridge functions (uses lore core)
|
||||||
crates/lore-tui/src/db_manager.rs # DbManager: closure-based read pool (with_reader) + dedicated writer (with_writer). Prevents lock-poison panics and accidental long-held guards.
|
crates/lore-tui/src/db_manager.rs # DbManager: closure-based read pool (with_reader) + dedicated writer (with_writer). Prevents lock-poison panics and accidental long-held guards.
|
||||||
crates/lore-tui/src/task_supervisor.rs # TaskSupervisor: unified submit() → TaskHandle API with dedup, cancellation, generation IDs, and priority lanes
|
crates/lore-tui/src/task_supervisor.rs # TaskSupervisor: unified submit() → TaskHandle API with dedup, cancellation, generation IDs, and priority lanes
|
||||||
crates/lore-tui/src/entity_cache.rs # Bounded LRU cache for IssueDetail/MrDetail payloads. Keyed by EntityKey. Invalidated on sync completion. Enables near-instant reopen during Enter/Esc drill-in/out workflows.
|
crates/lore-tui/src/entity_cache.rs # Bounded LRU cache for IssueDetail/MrDetail payloads. Keyed by EntityKey. Selective invalidation by changed EntityKey set (not blanket invalidate_all). Optional post-sync prewarm of top changed entities. Enables near-instant reopen during Enter/Esc drill-in/out workflows.
|
||||||
|
crates/lore-tui/src/render_cache.rs # Width/theme/content-hash keyed cache for expensive render artifacts (markdown → styled text, discussion tree shaping). Prevents per-frame recomputation.
|
||||||
|
crates/lore-tui/src/filter_dsl.rs # Typed filter bar DSL parser: quoted values, negation prefix, field:value syntax, inline diagnostics with cursor position. Replaces brittle split_whitespace() parsing.
|
||||||
|
crates/lore-tui/src/progress_coalescer.rs # Per-lane progress event coalescer. Batches progress updates at <=30Hz per lane key (project x resource_type) to reduce render pressure during sync.
|
||||||
|
crates/lore-tui/src/sync_delta_ledger.rs # In-memory per-run exact changed/new entity IDs (issues, MRs, discussions). Populated from SyncCompleted result. Used by Sync Summary mode for exact "what changed" navigation without new DB tables. Cleared on next sync run start.
|
||||||
|
crates/lore-tui/src/scope.rs # Global project scope context (AllProjects or pinned project set). Flows through all query bridge functions. Persisted in session state. `P` keybinding opens scope picker overlay.
|
||||||
crates/lore-tui/src/crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task/build snapshot. Captured by panic hook for post-mortem crash diagnostics with retention policy (latest 20 files).
|
crates/lore-tui/src/crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task/build snapshot. Captured by panic hook for post-mortem crash diagnostics with retention policy (latest 20 files).
|
||||||
crates/lore-tui/src/safety.rs # sanitize_for_terminal(), safe_url_policy()
|
crates/lore-tui/src/safety.rs # sanitize_for_terminal(), safe_url_policy()
|
||||||
crates/lore-tui/src/redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting
|
crates/lore-tui/src/redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting
|
||||||
@@ -3389,25 +3517,56 @@ pub fn sanitize_for_terminal(input: &str) -> String {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a URL against the configured GitLab origin(s) before opening.
|
/// Classify a URL's safety level against the configured GitLab origin(s) and
|
||||||
/// Enforces scheme + normalized host + port match to prevent deceptive variants
|
/// known entity path patterns before opening in browser.
|
||||||
/// (e.g., IDN homograph attacks, unexpected port redirects).
|
/// Returns tri-state: AllowedEntityPath (open immediately), AllowedButUnrecognizedPath
|
||||||
pub fn is_safe_url(url: &str, allowed_origins: &[AllowedOrigin]) -> bool {
|
/// (prompt user to confirm), or Blocked (refuse to open).
|
||||||
let Ok(parsed) = url::Url::parse(url) else { return false };
|
pub fn classify_safe_url(url: &str, policy: &UrlPolicy) -> UrlSafety {
|
||||||
|
let Ok(parsed) = url::Url::parse(url) else { return UrlSafety::Blocked };
|
||||||
|
|
||||||
// Only allow HTTPS
|
// Only allow HTTPS
|
||||||
if parsed.scheme() != "https" { return false; }
|
if parsed.scheme() != "https" { return UrlSafety::Blocked; }
|
||||||
|
|
||||||
// Normalize host (lowercase, IDNA-compatible) and match scheme+host+port
|
// Normalize host (lowercase, IDNA-compatible) and match scheme+host+port
|
||||||
let Some(host) = parsed.host_str() else { return false; };
|
let Some(host) = parsed.host_str() else { return UrlSafety::Blocked; };
|
||||||
let host_lower = host.to_ascii_lowercase();
|
let host_lower = host.to_ascii_lowercase();
|
||||||
let port = parsed.port_or_known_default();
|
let port = parsed.port_or_known_default();
|
||||||
|
|
||||||
allowed_origins.iter().any(|origin| {
|
let origin_match = policy.allowed_origins.iter().any(|origin| {
|
||||||
origin.scheme == "https"
|
origin.scheme == "https"
|
||||||
&& origin.host == host_lower
|
&& origin.host == host_lower
|
||||||
&& origin.port == port
|
&& origin.port == port
|
||||||
})
|
});
|
||||||
|
|
||||||
|
if !origin_match {
|
||||||
|
return UrlSafety::Blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check path against known GitLab entity patterns
|
||||||
|
let path = parsed.path();
|
||||||
|
if policy.entity_path_patterns.iter().any(|pat| pat.matches(path)) {
|
||||||
|
UrlSafety::AllowedEntityPath
|
||||||
|
} else {
|
||||||
|
UrlSafety::AllowedButUnrecognizedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tri-state URL safety classification.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum UrlSafety {
|
||||||
|
/// Known GitLab entity path — open immediately without prompt.
|
||||||
|
AllowedEntityPath,
|
||||||
|
/// Same host but unrecognized path — show confirmation modal before opening.
|
||||||
|
AllowedButUnrecognizedPath,
|
||||||
|
/// Different host, wrong scheme, or parse failure — refuse to open.
|
||||||
|
Blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL validation policy: allowed origins + known GitLab entity path patterns.
|
||||||
|
pub struct UrlPolicy {
|
||||||
|
pub allowed_origins: Vec<AllowedOrigin>,
|
||||||
|
/// Path patterns for known GitLab entity routes (e.g., `/-/issues/`, `/-/merge_requests/`).
|
||||||
|
pub entity_path_patterns: Vec<PathPattern>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed origin for URL validation (scheme + normalized host + port).
|
/// Typed origin for URL validation (scheme + normalized host + port).
|
||||||
@@ -4285,6 +4444,7 @@ pub struct AppState {
|
|||||||
pub command_palette: CommandPaletteState,
|
pub command_palette: CommandPaletteState,
|
||||||
|
|
||||||
// Cross-cutting state
|
// Cross-cutting state
|
||||||
|
pub global_scope: ScopeContext, // Applies to dashboard/list/search/timeline/who queries. Default: AllProjects.
|
||||||
pub load_state: ScreenLoadStateMap,
|
pub load_state: ScreenLoadStateMap,
|
||||||
pub error_toast: Option<String>,
|
pub error_toast: Option<String>,
|
||||||
pub show_help: bool,
|
pub show_help: bool,
|
||||||
@@ -5445,15 +5605,20 @@ pub fn fetch_dashboard(conn: &Connection) -> Result<DashboardData, LoreError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch issues, converting TUI IssueFilter → CLI ListFilters.
|
/// Fetch issues, converting TUI IssueFilter → CLI ListFilters.
|
||||||
|
/// The `scope` parameter applies global project pinning — when a scope is active,
|
||||||
|
/// it overrides any per-filter project selection, ensuring cross-screen consistency.
|
||||||
pub fn fetch_issues(
|
pub fn fetch_issues(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
scope: &ScopeContext,
|
||||||
filter: &IssueFilter,
|
filter: &IssueFilter,
|
||||||
) -> Result<Vec<IssueListRow>, LoreError> {
|
) -> Result<Vec<IssueListRow>, LoreError> {
|
||||||
// Convert TUI filter to CLI filter format.
|
// Convert TUI filter to CLI filter format.
|
||||||
// The CLI already has query_issues() — we just need to bridge the types.
|
// The CLI already has query_issues() — we just need to bridge the types.
|
||||||
|
// Global scope overrides per-filter project when active.
|
||||||
|
let effective_project = scope.effective_project(filter.project.as_deref());
|
||||||
let cli_filter = ListFilters {
|
let cli_filter = ListFilters {
|
||||||
limit: filter.limit,
|
limit: filter.limit,
|
||||||
project: filter.project.as_deref(),
|
project: effective_project.as_deref(),
|
||||||
state: filter.state.as_deref(),
|
state: filter.state.as_deref(),
|
||||||
author: filter.author.as_deref(),
|
author: filter.author.as_deref(),
|
||||||
assignee: filter.assignee.as_deref(),
|
assignee: filter.assignee.as_deref(),
|
||||||
@@ -7806,3 +7971,13 @@ Recommendations from external review (feedback-8, ChatGPT) that were evaluated a
|
|||||||
Recommendations from external review (feedback-9, ChatGPT) that were evaluated and declined:
|
Recommendations from external review (feedback-9, ChatGPT) that were evaluated and declined:
|
||||||
|
|
||||||
- **Search Facets panel (entity type counts, top labels/projects/authors with one-key apply)** — rejected as feature scope expansion for v1. The concept (three-pane layout with facet counts and quick-apply shortcuts like `1/2/3` for type facets, `l` for label cycling) is compelling and would make search more actionable for triage workflows. However, it requires: new aggregate queries for facet counting that must perform well across all three data tiers, a third layout pane that breaks the current two-pane split design, new keybinding slots (`1/2/3/l`) that could conflict with future list navigation, and per-query facet recalculation that adds latency. The existing search with explicit field-based filters is sufficient for v1. Facets are a strong v2 candidate — once search has production mileage and users report wanting faster triage filtering, the aggregate query patterns and UI layout can be designed with real usage data.
|
- **Search Facets panel (entity type counts, top labels/projects/authors with one-key apply)** — rejected as feature scope expansion for v1. The concept (three-pane layout with facet counts and quick-apply shortcuts like `1/2/3` for type facets, `l` for label cycling) is compelling and would make search more actionable for triage workflows. However, it requires: new aggregate queries for facet counting that must perform well across all three data tiers, a third layout pane that breaks the current two-pane split design, new keybinding slots (`1/2/3/l`) that could conflict with future list navigation, and per-query facet recalculation that adds latency. The existing search with explicit field-based filters is sufficient for v1. Facets are a strong v2 candidate — once search has production mileage and users report wanting faster triage filtering, the aggregate query patterns and UI layout can be designed with real usage data.
|
||||||
|
|
||||||
|
Recommendations from external review (feedback-10, ChatGPT) that were evaluated and declined:
|
||||||
|
|
||||||
|
- **Structured compat handshake (`--compat-json` replacing `--compat-version` integer)** — rejected because the current two-step contract (integer compat version + separate schema version check) is intentionally minimal and robust. Adding JSON parsing (`{ "protocol": 1, "compat_version": 2, "min_schema": 14, "max_schema": 16, "build": "..." }`) to a preflight binary validation introduces a new failure mode (malformed JSON, missing fields, version parsing) for zero user-visible benefit. The integer check detects "too old to work" — the only case that matters before spawning the TUI. Schema range is already validated separately via `--check-schema`. Combining both into a single JSON response couples concerns that are better kept independent (binary compat vs schema compat). The current approach is more resilient: if `--compat-version` is missing (old binary), we warn and proceed; JSON parsing failure would be a hard error. KISS principle applies.
|
||||||
|
|
||||||
|
Recommendations from external review (feedback-11, ChatGPT) that were evaluated and declined:
|
||||||
|
|
||||||
|
- **Query budgets and soft deadlines (120ms/250ms hard deadlines with `QueryDegraded` truncation)** — rejected as over-engineering for a local SQLite tool. The proposal adds per-query-type latency budgets (list: 250ms, detail: 150ms, search: 250ms hard deadline) with SQLite progress handler interrupts and inline "results truncated" badges. This papers over slow queries with UX complexity rather than fixing the root cause. If a list query exceeds 250ms on a local SQLite database, the correct fix is adding an index or optimizing the query plan — not truncating results and showing a retry badge. The existing cancellation + stale-drop system already handles the interactive case (user navigates away before query completes). SQLite progress handlers are also tricky to implement correctly — they fire on every VM instruction, adding overhead to all queries, and the cancellation semantics interact poorly with SQLite's transaction semantics. The complexity-to-benefit ratio is wrong for a single-user local tool. If specific queries are slow, we fix them at the query/index level (Section 9.3.1 already documents required covering indexes).
|
||||||
|
|
||||||
|
- **Adaptive render governor (runtime frame-time monitoring with automatic profile downgrading)** — rejected for the same reason as feedback-3's SLO telemetry and runtime monitoring proposals. The proposal adds a frame-time p95 sliding window, stream pressure detection, automatic profile switching (quality/balanced/speed), hysteresis for recovery, and a `--render-profile` CLI flag. This is appropriate for a multi-user rendering engine or game, not a single-user TUI. The capability detection in Section 3.4.1 already handles the static case (detect terminal capabilities, choose appropriate rendering). If the TUI is slow in tmux or over SSH, the user can pass `--ascii` or reduce their terminal size. Adding a runtime monitoring system with automatic visual degradation introduces a state machine, requires frame-time measurement infrastructure, needs hysteresis tuning to avoid flapping, and must be tested across all the profiles it can switch between. This is significant complexity for an edge case that affects one user once and is solved by a flag. The `--render-profile` flag itself is a reasonable addition as a static override — but the dynamic adaptation runtime is rejected.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ pub enum CorrectionRule {
|
|||||||
SingleDashLongFlag,
|
SingleDashLongFlag,
|
||||||
CaseNormalization,
|
CaseNormalization,
|
||||||
FuzzyFlag,
|
FuzzyFlag,
|
||||||
|
SubcommandAlias,
|
||||||
|
ValueNormalization,
|
||||||
|
ValueFuzzy,
|
||||||
|
FlagPrefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of the correction pass over raw args.
|
/// Result of the correction pass over raw args.
|
||||||
@@ -40,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
|
|||||||
"--robot",
|
"--robot",
|
||||||
"--json",
|
"--json",
|
||||||
"--color",
|
"--color",
|
||||||
|
"--icons",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--no-quiet",
|
"--no-quiet",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
@@ -119,8 +124,10 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
"--no-docs",
|
"--no-docs",
|
||||||
"--no-events",
|
"--no-events",
|
||||||
"--no-file-changes",
|
"--no-file-changes",
|
||||||
|
"--no-status",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--no-dry-run",
|
"--no-dry-run",
|
||||||
|
"--timings",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -162,7 +169,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
"--project",
|
"--project",
|
||||||
"--since",
|
"--since",
|
||||||
"--depth",
|
"--depth",
|
||||||
"--expand-mentions",
|
"--no-mentions",
|
||||||
"--limit",
|
"--limit",
|
||||||
"--fields",
|
"--fields",
|
||||||
"--max-seeds",
|
"--max-seeds",
|
||||||
@@ -183,6 +190,36 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
"--fields",
|
"--fields",
|
||||||
"--detail",
|
"--detail",
|
||||||
"--no-detail",
|
"--no-detail",
|
||||||
|
"--as-of",
|
||||||
|
"--explain-score",
|
||||||
|
"--include-bots",
|
||||||
|
"--all-history",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("drift", &["--threshold", "--project"]),
|
||||||
|
(
|
||||||
|
"notes",
|
||||||
|
&[
|
||||||
|
"--limit",
|
||||||
|
"--fields",
|
||||||
|
"--format",
|
||||||
|
"--author",
|
||||||
|
"--note-type",
|
||||||
|
"--contains",
|
||||||
|
"--note-id",
|
||||||
|
"--gitlab-note-id",
|
||||||
|
"--discussion-id",
|
||||||
|
"--include-system",
|
||||||
|
"--for-issue",
|
||||||
|
"--for-mr",
|
||||||
|
"--project",
|
||||||
|
"--since",
|
||||||
|
"--until",
|
||||||
|
"--path",
|
||||||
|
"--resolution",
|
||||||
|
"--sort",
|
||||||
|
"--asc",
|
||||||
|
"--open",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -193,6 +230,26 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
"--gitlab-url",
|
"--gitlab-url",
|
||||||
"--token-env-var",
|
"--token-env-var",
|
||||||
"--projects",
|
"--projects",
|
||||||
|
"--default-project",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file-history",
|
||||||
|
&[
|
||||||
|
"--project",
|
||||||
|
"--discussions",
|
||||||
|
"--no-follow-renames",
|
||||||
|
"--merged",
|
||||||
|
"--limit",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trace",
|
||||||
|
&[
|
||||||
|
"--project",
|
||||||
|
"--discussions",
|
||||||
|
"--no-follow-renames",
|
||||||
|
"--limit",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("generate-docs", &["--full", "--project"]),
|
("generate-docs", &["--full", "--project"]),
|
||||||
@@ -230,18 +287,47 @@ pub const ENUM_VALUES: &[(&str, &[&str])] = &[
|
|||||||
("--state", &["opened", "closed", "merged", "locked", "all"]),
|
("--state", &["opened", "closed", "merged", "locked", "all"]),
|
||||||
("--mode", &["lexical", "hybrid", "semantic"]),
|
("--mode", &["lexical", "hybrid", "semantic"]),
|
||||||
("--sort", &["updated", "created", "iid"]),
|
("--sort", &["updated", "created", "iid"]),
|
||||||
("--type", &["issue", "mr", "discussion"]),
|
("--type", &["issue", "mr", "discussion", "note"]),
|
||||||
("--fts-mode", &["safe", "raw"]),
|
("--fts-mode", &["safe", "raw"]),
|
||||||
("--color", &["auto", "always", "never"]),
|
("--color", &["auto", "always", "never"]),
|
||||||
("--log-format", &["text", "json"]),
|
("--log-format", &["text", "json"]),
|
||||||
("--for", &["issue", "mr"]),
|
("--for", &["issue", "mr"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Subcommand alias map (for forms clap aliases can't express)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Subcommand aliases for non-standard forms (underscores, no separators).
|
||||||
|
/// Clap `visible_alias`/`alias` handles hyphenated forms (`merge-requests`);
|
||||||
|
/// this map catches the rest.
|
||||||
|
const SUBCOMMAND_ALIASES: &[(&str, &str)] = &[
|
||||||
|
("merge_requests", "mrs"),
|
||||||
|
("merge_request", "mrs"),
|
||||||
|
("mergerequests", "mrs"),
|
||||||
|
("mergerequest", "mrs"),
|
||||||
|
("generate_docs", "generate-docs"),
|
||||||
|
("generatedocs", "generate-docs"),
|
||||||
|
("gendocs", "generate-docs"),
|
||||||
|
("gen-docs", "generate-docs"),
|
||||||
|
("robot_docs", "robot-docs"),
|
||||||
|
("robotdocs", "robot-docs"),
|
||||||
|
("sync_status", "status"),
|
||||||
|
("syncstatus", "status"),
|
||||||
|
("auth_test", "auth"),
|
||||||
|
("authtest", "auth"),
|
||||||
|
("file_history", "file-history"),
|
||||||
|
("filehistory", "file-history"),
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Correction thresholds
|
// Correction thresholds
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const FUZZY_FLAG_THRESHOLD: f64 = 0.8;
|
const FUZZY_FLAG_THRESHOLD: f64 = 0.8;
|
||||||
|
/// Stricter threshold for robot mode — only high-confidence corrections to
|
||||||
|
/// avoid misleading agents. Still catches obvious typos like `--projct`.
|
||||||
|
const FUZZY_FLAG_THRESHOLD_STRICT: f64 = 0.9;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core logic
|
// Core logic
|
||||||
@@ -301,20 +387,29 @@ fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> {
|
|||||||
|
|
||||||
/// Run the pre-clap correction pass on raw args.
|
/// Run the pre-clap correction pass on raw args.
|
||||||
///
|
///
|
||||||
/// When `strict` is true (robot mode), only deterministic corrections are applied
|
/// Three-phase pipeline:
|
||||||
/// (single-dash long flags, case normalization). Fuzzy matching is disabled to
|
/// - Phase A: Subcommand alias correction (case-insensitive alias map)
|
||||||
/// prevent misleading agents with speculative corrections.
|
/// - Phase B: Per-arg flag corrections (single-dash, case, prefix, fuzzy)
|
||||||
|
/// - Phase C: Enum value normalization (case + fuzzy + prefix on known values)
|
||||||
|
///
|
||||||
|
/// When `strict` is true (robot mode), fuzzy matching uses a higher threshold
|
||||||
|
/// (0.9 vs 0.8) to avoid speculative corrections while still catching obvious
|
||||||
|
/// typos like `--projct` → `--project`.
|
||||||
///
|
///
|
||||||
/// Returns the (possibly modified) args and any corrections applied.
|
/// Returns the (possibly modified) args and any corrections applied.
|
||||||
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
||||||
let subcommand = detect_subcommand(&raw);
|
|
||||||
let valid = valid_flags_for(subcommand);
|
|
||||||
|
|
||||||
let mut corrected = Vec::with_capacity(raw.len());
|
|
||||||
let mut corrections = Vec::new();
|
let mut corrections = Vec::new();
|
||||||
|
|
||||||
|
// Phase A: Subcommand alias correction
|
||||||
|
let args = correct_subcommand(raw, &mut corrections);
|
||||||
|
|
||||||
|
// Phase B: Per-arg flag corrections
|
||||||
|
let valid = valid_flags_for(detect_subcommand(&args));
|
||||||
|
|
||||||
|
let mut corrected = Vec::with_capacity(args.len());
|
||||||
let mut past_terminator = false;
|
let mut past_terminator = false;
|
||||||
|
|
||||||
for arg in raw {
|
for arg in args {
|
||||||
// B1: Stop correcting after POSIX `--` option terminator
|
// B1: Stop correcting after POSIX `--` option terminator
|
||||||
if arg == "--" {
|
if arg == "--" {
|
||||||
past_terminator = true;
|
past_terminator = true;
|
||||||
@@ -336,12 +431,177 @@ pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase C: Enum value normalization
|
||||||
|
normalize_enum_values(&mut corrected, &mut corrections);
|
||||||
|
|
||||||
CorrectionResult {
|
CorrectionResult {
|
||||||
args: corrected,
|
args: corrected,
|
||||||
corrections,
|
corrections,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase A: Replace subcommand aliases with their canonical names.
|
||||||
|
///
|
||||||
|
/// Handles forms that can't be expressed as clap `alias`/`visible_alias`
|
||||||
|
/// (underscores, no-separator forms). Case-insensitive matching.
|
||||||
|
fn correct_subcommand(mut args: Vec<String>, corrections: &mut Vec<Correction>) -> Vec<String> {
|
||||||
|
// Find the subcommand position index, then check the alias map.
|
||||||
|
// Can't use iterators easily because we need to mutate args[i].
|
||||||
|
let mut skip_next = false;
|
||||||
|
let mut subcmd_idx = None;
|
||||||
|
for (i, arg) in args.iter().enumerate().skip(1) {
|
||||||
|
if skip_next {
|
||||||
|
skip_next = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if arg.starts_with('-') {
|
||||||
|
if arg.contains('=') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(arg.as_str(), "--config" | "-c" | "--color" | "--log-format") {
|
||||||
|
skip_next = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
subcmd_idx = Some(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(i) = subcmd_idx
|
||||||
|
&& let Some((_, canonical)) = SUBCOMMAND_ALIASES
|
||||||
|
.iter()
|
||||||
|
.find(|(alias, _)| alias.eq_ignore_ascii_case(&args[i]))
|
||||||
|
{
|
||||||
|
corrections.push(Correction {
|
||||||
|
original: args[i].clone(),
|
||||||
|
corrected: (*canonical).to_string(),
|
||||||
|
rule: CorrectionRule::SubcommandAlias,
|
||||||
|
confidence: 1.0,
|
||||||
|
});
|
||||||
|
args[i] = (*canonical).to_string();
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase C: Normalize enum values for flags with known valid values.
|
||||||
|
///
|
||||||
|
/// Handles both `--flag value` and `--flag=value` forms. Corrections are:
|
||||||
|
/// 1. Case normalization: `Opened` → `opened`
|
||||||
|
/// 2. Prefix expansion: `open` → `opened` (only if unambiguous)
|
||||||
|
/// 3. Fuzzy matching: `opend` → `opened`
|
||||||
|
fn normalize_enum_values(args: &mut [String], corrections: &mut Vec<Correction>) {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
// Respect POSIX `--` option terminator — don't normalize values after it
|
||||||
|
if args[i] == "--" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --flag=value form
|
||||||
|
if let Some(eq_pos) = args[i].find('=') {
|
||||||
|
let flag = args[i][..eq_pos].to_string();
|
||||||
|
let value = args[i][eq_pos + 1..].to_string();
|
||||||
|
if let Some(valid_vals) = lookup_enum_values(&flag)
|
||||||
|
&& let Some((corrected_val, is_case_only)) = normalize_value(&value, valid_vals)
|
||||||
|
{
|
||||||
|
let original = args[i].clone();
|
||||||
|
let corrected = format!("{flag}={corrected_val}");
|
||||||
|
args[i] = corrected.clone();
|
||||||
|
corrections.push(Correction {
|
||||||
|
original,
|
||||||
|
corrected,
|
||||||
|
rule: if is_case_only {
|
||||||
|
CorrectionRule::ValueNormalization
|
||||||
|
} else {
|
||||||
|
CorrectionRule::ValueFuzzy
|
||||||
|
},
|
||||||
|
confidence: 0.95,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --flag value form
|
||||||
|
if args[i].starts_with("--")
|
||||||
|
&& let Some(valid_vals) = lookup_enum_values(&args[i])
|
||||||
|
&& i + 1 < args.len()
|
||||||
|
&& !args[i + 1].starts_with('-')
|
||||||
|
{
|
||||||
|
let value = args[i + 1].clone();
|
||||||
|
if let Some((corrected_val, is_case_only)) = normalize_value(&value, valid_vals) {
|
||||||
|
let original = args[i + 1].clone();
|
||||||
|
args[i + 1] = corrected_val.to_string();
|
||||||
|
corrections.push(Correction {
|
||||||
|
original,
|
||||||
|
corrected: corrected_val.to_string(),
|
||||||
|
rule: if is_case_only {
|
||||||
|
CorrectionRule::ValueNormalization
|
||||||
|
} else {
|
||||||
|
CorrectionRule::ValueFuzzy
|
||||||
|
},
|
||||||
|
confidence: 0.95,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up valid enum values for a flag (case-insensitive flag name match).
|
||||||
|
fn lookup_enum_values(flag: &str) -> Option<&'static [&'static str]> {
|
||||||
|
let lower = flag.to_lowercase();
|
||||||
|
ENUM_VALUES
|
||||||
|
.iter()
|
||||||
|
.find(|(f, _)| f.to_lowercase() == lower)
|
||||||
|
.map(|(_, vals)| *vals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to normalize a value against a set of valid values.
|
||||||
|
///
|
||||||
|
/// Returns `Some((corrected, is_case_only))` if a correction is needed:
|
||||||
|
/// - `is_case_only = true` for pure case normalization
|
||||||
|
/// - `is_case_only = false` for prefix/fuzzy corrections
|
||||||
|
///
|
||||||
|
/// Returns `None` if the value is already valid or no match is found.
|
||||||
|
fn normalize_value(input: &str, valid_values: &[&str]) -> Option<(String, bool)> {
|
||||||
|
// Already valid (exact match)? No correction needed.
|
||||||
|
if valid_values.contains(&input) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = input.to_lowercase();
|
||||||
|
|
||||||
|
// Case-insensitive exact match
|
||||||
|
if let Some(&val) = valid_values.iter().find(|v| v.to_lowercase() == lower) {
|
||||||
|
return Some((val.to_string(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix match (e.g., "open" → "opened") — only if unambiguous
|
||||||
|
let prefix_matches: Vec<&&str> = valid_values
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.starts_with(&*lower))
|
||||||
|
.collect();
|
||||||
|
if prefix_matches.len() == 1 {
|
||||||
|
return Some(((*prefix_matches[0]).to_string(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match
|
||||||
|
let best = valid_values
|
||||||
|
.iter()
|
||||||
|
.map(|v| (*v, jaro_winkler(&lower, v)))
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
if let Some((val, score)) = best
|
||||||
|
&& score >= 0.8
|
||||||
|
{
|
||||||
|
return Some((val.to_string(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Clap built-in flags that should never be corrected. These are handled by clap
|
/// Clap built-in flags that should never be corrected. These are handled by clap
|
||||||
/// directly and are not in our GLOBAL_FLAGS registry.
|
/// directly and are not in our GLOBAL_FLAGS registry.
|
||||||
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
||||||
@@ -460,10 +720,34 @@ fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correcti
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 3: Fuzzy flag match — `--staate` -> `--state` (skip in strict mode)
|
// Rule 3: Prefix match — `--proj` -> `--project` (only if unambiguous)
|
||||||
if !strict
|
let prefix_matches: Vec<&str> = valid_flags
|
||||||
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
.iter()
|
||||||
&& score >= FUZZY_FLAG_THRESHOLD
|
.filter(|f| f.starts_with(&*lower) && f.to_lowercase() != lower)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if prefix_matches.len() == 1 {
|
||||||
|
let matched = prefix_matches[0];
|
||||||
|
let corrected = match value_suffix {
|
||||||
|
Some(suffix) => format!("{matched}{suffix}"),
|
||||||
|
None => matched.to_string(),
|
||||||
|
};
|
||||||
|
return Some(Correction {
|
||||||
|
original: arg.to_string(),
|
||||||
|
corrected,
|
||||||
|
rule: CorrectionRule::FlagPrefix,
|
||||||
|
confidence: 0.95,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 4: Fuzzy flag match — higher threshold in strict/robot mode
|
||||||
|
let threshold = if strict {
|
||||||
|
FUZZY_FLAG_THRESHOLD_STRICT
|
||||||
|
} else {
|
||||||
|
FUZZY_FLAG_THRESHOLD
|
||||||
|
};
|
||||||
|
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||||
|
&& score >= threshold
|
||||||
{
|
{
|
||||||
let corrected = match value_suffix {
|
let corrected = match value_suffix {
|
||||||
Some(suffix) => format!("{best_flag}{suffix}"),
|
Some(suffix) => format!("{best_flag}{suffix}"),
|
||||||
@@ -537,6 +821,30 @@ pub fn format_teaching_note(correction: &Correction) -> String {
|
|||||||
correction.corrected, correction.original
|
correction.corrected, correction.original
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
CorrectionRule::SubcommandAlias => {
|
||||||
|
format!(
|
||||||
|
"Use canonical command name: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CorrectionRule::ValueNormalization => {
|
||||||
|
format!(
|
||||||
|
"Values are lowercase: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CorrectionRule::ValueFuzzy => {
|
||||||
|
format!(
|
||||||
|
"Correct value spelling: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CorrectionRule::FlagPrefix => {
|
||||||
|
format!(
|
||||||
|
"Use full flag name: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,17 +1028,20 @@ mod tests {
|
|||||||
assert_eq!(result.args[1], "--help");
|
assert_eq!(result.args[1], "--help");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- I6: Strict mode (robot) disables fuzzy matching ----
|
// ---- Strict mode (robot) uses higher fuzzy threshold ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strict_mode_disables_fuzzy() {
|
fn strict_mode_rejects_low_confidence_fuzzy() {
|
||||||
// Fuzzy match works in non-strict
|
// `--staate` vs `--state` — close but may be below strict threshold (0.9)
|
||||||
|
// The exact score depends on Jaro-Winkler; this tests that the strict
|
||||||
|
// threshold is higher than non-strict.
|
||||||
let non_strict = correct_args(args("lore --robot issues --staate opened"), false);
|
let non_strict = correct_args(args("lore --robot issues --staate opened"), false);
|
||||||
assert_eq!(non_strict.corrections.len(), 1);
|
assert_eq!(non_strict.corrections.len(), 1);
|
||||||
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||||
|
|
||||||
// Fuzzy match disabled in strict
|
// In strict mode, same typo might or might not match depending on JW score.
|
||||||
let strict = correct_args(args("lore --robot issues --staate opened"), true);
|
// We verify that at least wildly wrong flags are still rejected.
|
||||||
|
let strict = correct_args(args("lore --robot issues --xyzzy foo"), true);
|
||||||
assert!(strict.corrections.is_empty());
|
assert!(strict.corrections.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +1060,155 @@ mod tests {
|
|||||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Subcommand alias correction ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_alias_merge_requests_underscore() {
|
||||||
|
let result = correct_args(args("lore --robot merge_requests -n 10"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.rule == CorrectionRule::SubcommandAlias && c.corrected == "mrs")
|
||||||
|
);
|
||||||
|
assert!(result.args.contains(&"mrs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_alias_mergerequests_no_sep() {
|
||||||
|
let result = correct_args(args("lore --robot mergerequests"), false);
|
||||||
|
assert!(result.corrections.iter().any(|c| c.corrected == "mrs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_alias_generate_docs_underscore() {
|
||||||
|
let result = correct_args(args("lore generate_docs"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.corrected == "generate-docs")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_alias_case_insensitive() {
|
||||||
|
let result = correct_args(args("lore Merge_Requests"), false);
|
||||||
|
assert!(result.corrections.iter().any(|c| c.corrected == "mrs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_alias_valid_command_untouched() {
|
||||||
|
let result = correct_args(args("lore issues -n 10"), false);
|
||||||
|
assert!(result.corrections.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Enum value normalization ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_case_normalization() {
|
||||||
|
let result = correct_args(args("lore issues --state Opened"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.rule == CorrectionRule::ValueNormalization && c.corrected == "opened")
|
||||||
|
);
|
||||||
|
assert!(result.args.contains(&"opened".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_case_normalization_eq_form() {
|
||||||
|
let result = correct_args(args("lore issues --state=Opened"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.corrected == "--state=opened")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_prefix_expansion() {
|
||||||
|
// "open" is a unique prefix of "opened"
|
||||||
|
let result = correct_args(args("lore issues --state open"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.corrected == "opened" && c.rule == CorrectionRule::ValueFuzzy)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_fuzzy_typo() {
|
||||||
|
let result = correct_args(args("lore issues --state opend"), false);
|
||||||
|
assert!(result.corrections.iter().any(|c| c.corrected == "opened"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_already_valid_untouched() {
|
||||||
|
let result = correct_args(args("lore issues --state opened"), false);
|
||||||
|
// No value corrections expected (flag corrections may still exist)
|
||||||
|
assert!(!result.corrections.iter().any(|c| matches!(
|
||||||
|
c.rule,
|
||||||
|
CorrectionRule::ValueNormalization | CorrectionRule::ValueFuzzy
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_mode_case() {
|
||||||
|
let result = correct_args(args("lore search --mode Hybrid query"), false);
|
||||||
|
assert!(result.corrections.iter().any(|c| c.corrected == "hybrid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_normalization_respects_option_terminator() {
|
||||||
|
// Values after `--` are positional and must not be corrected
|
||||||
|
let result = correct_args(args("lore search -- --state Opened"), false);
|
||||||
|
assert!(!result.corrections.iter().any(|c| matches!(
|
||||||
|
c.rule,
|
||||||
|
CorrectionRule::ValueNormalization | CorrectionRule::ValueFuzzy
|
||||||
|
)));
|
||||||
|
assert_eq!(result.args[4], "Opened"); // preserved as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Flag prefix matching ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_prefix_project() {
|
||||||
|
let result = correct_args(args("lore issues --proj group/repo"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.rule == CorrectionRule::FlagPrefix && c.corrected == "--project")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_prefix_ambiguous_not_corrected() {
|
||||||
|
// --s could be --state, --since, --sort, --status — ambiguous
|
||||||
|
let result = correct_args(args("lore issues --s opened"), false);
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.rule == CorrectionRule::FlagPrefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_prefix_with_eq_value() {
|
||||||
|
let result = correct_args(args("lore issues --proj=group/repo"), false);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.corrected == "--project=group/repo")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Teaching notes ----
|
// ---- Teaching notes ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -788,6 +1248,43 @@ mod tests {
|
|||||||
assert!(note.contains("spelling"));
|
assert!(note.contains("spelling"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teaching_note_subcommand_alias() {
|
||||||
|
let c = Correction {
|
||||||
|
original: "merge_requests".to_string(),
|
||||||
|
corrected: "mrs".to_string(),
|
||||||
|
rule: CorrectionRule::SubcommandAlias,
|
||||||
|
confidence: 1.0,
|
||||||
|
};
|
||||||
|
let note = format_teaching_note(&c);
|
||||||
|
assert!(note.contains("canonical"));
|
||||||
|
assert!(note.contains("mrs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teaching_note_value_normalization() {
|
||||||
|
let c = Correction {
|
||||||
|
original: "Opened".to_string(),
|
||||||
|
corrected: "opened".to_string(),
|
||||||
|
rule: CorrectionRule::ValueNormalization,
|
||||||
|
confidence: 0.95,
|
||||||
|
};
|
||||||
|
let note = format_teaching_note(&c);
|
||||||
|
assert!(note.contains("lowercase"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teaching_note_flag_prefix() {
|
||||||
|
let c = Correction {
|
||||||
|
original: "--proj".to_string(),
|
||||||
|
corrected: "--project".to_string(),
|
||||||
|
rule: CorrectionRule::FlagPrefix,
|
||||||
|
confidence: 0.95,
|
||||||
|
};
|
||||||
|
let note = format_teaching_note(&c);
|
||||||
|
assert!(note.contains("full flag name"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Post-clap suggestion helpers ----
|
// ---- Post-clap suggestion helpers ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::{self, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -178,27 +178,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_number(n: i64) -> String {
|
|
||||||
let (prefix, abs) = if n < 0 {
|
|
||||||
("-", n.unsigned_abs())
|
|
||||||
} else {
|
|
||||||
("", n.unsigned_abs())
|
|
||||||
};
|
|
||||||
|
|
||||||
let s = abs.to_string();
|
|
||||||
let chars: Vec<char> = s.chars().collect();
|
|
||||||
let mut result = String::from(prefix);
|
|
||||||
|
|
||||||
for (i, c) in chars.iter().enumerate() {
|
|
||||||
if i > 0 && (chars.len() - i).is_multiple_of(3) {
|
|
||||||
result.push(',');
|
|
||||||
}
|
|
||||||
result.push(*c);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct CountJsonOutput {
|
struct CountJsonOutput {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -284,10 +263,10 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
|
|||||||
pub fn print_event_count(counts: &EventCounts) {
|
pub fn print_event_count(counts: &EventCounts) {
|
||||||
println!(
|
println!(
|
||||||
"{:<20} {:>8} {:>8} {:>8}",
|
"{:<20} {:>8} {:>8} {:>8}",
|
||||||
style("Event Type").cyan().bold(),
|
Theme::info().bold().render("Event Type"),
|
||||||
style("Issues").bold(),
|
Theme::bold().render("Issues"),
|
||||||
style("MRs").bold(),
|
Theme::bold().render("MRs"),
|
||||||
style("Total").bold()
|
Theme::bold().render("Total")
|
||||||
);
|
);
|
||||||
|
|
||||||
let state_total = counts.state_issue + counts.state_mr;
|
let state_total = counts.state_issue + counts.state_mr;
|
||||||
@@ -297,33 +276,33 @@ pub fn print_event_count(counts: &EventCounts) {
|
|||||||
println!(
|
println!(
|
||||||
"{:<20} {:>8} {:>8} {:>8}",
|
"{:<20} {:>8} {:>8} {:>8}",
|
||||||
"State events",
|
"State events",
|
||||||
format_number(counts.state_issue as i64),
|
render::format_number(counts.state_issue as i64),
|
||||||
format_number(counts.state_mr as i64),
|
render::format_number(counts.state_mr as i64),
|
||||||
format_number(state_total as i64)
|
render::format_number(state_total as i64)
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"{:<20} {:>8} {:>8} {:>8}",
|
"{:<20} {:>8} {:>8} {:>8}",
|
||||||
"Label events",
|
"Label events",
|
||||||
format_number(counts.label_issue as i64),
|
render::format_number(counts.label_issue as i64),
|
||||||
format_number(counts.label_mr as i64),
|
render::format_number(counts.label_mr as i64),
|
||||||
format_number(label_total as i64)
|
render::format_number(label_total as i64)
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"{:<20} {:>8} {:>8} {:>8}",
|
"{:<20} {:>8} {:>8} {:>8}",
|
||||||
"Milestone events",
|
"Milestone events",
|
||||||
format_number(counts.milestone_issue as i64),
|
render::format_number(counts.milestone_issue as i64),
|
||||||
format_number(counts.milestone_mr as i64),
|
render::format_number(counts.milestone_mr as i64),
|
||||||
format_number(milestone_total as i64)
|
render::format_number(milestone_total as i64)
|
||||||
);
|
);
|
||||||
|
|
||||||
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
|
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
|
||||||
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
|
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
|
||||||
println!(
|
println!(
|
||||||
"{:<20} {:>8} {:>8} {:>8}",
|
"{:<20} {:>8} {:>8} {:>8}",
|
||||||
style("Total").bold(),
|
Theme::bold().render("Total"),
|
||||||
format_number(total_issues as i64),
|
render::format_number(total_issues as i64),
|
||||||
format_number(total_mrs as i64),
|
render::format_number(total_mrs as i64),
|
||||||
style(format_number(counts.total() as i64)).bold()
|
Theme::bold().render(&render::format_number(counts.total() as i64))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,57 +329,56 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_count(result: &CountResult) {
|
pub fn print_count(result: &CountResult) {
|
||||||
let count_str = format_number(result.count);
|
let count_str = render::format_number(result.count);
|
||||||
|
|
||||||
if let Some(system_count) = result.system_count {
|
if let Some(system_count) = result.system_count {
|
||||||
println!(
|
println!(
|
||||||
"{}: {} {}",
|
"{}: {:>10} {}",
|
||||||
style(&result.entity).cyan(),
|
Theme::info().render(&result.entity),
|
||||||
style(&count_str).bold(),
|
Theme::bold().render(&count_str),
|
||||||
style(format!(
|
Theme::dim().render(&format!(
|
||||||
"(excluding {} system)",
|
"(excluding {} system)",
|
||||||
format_number(system_count)
|
render::format_number(system_count)
|
||||||
))
|
))
|
||||||
.dim()
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}: {}",
|
"{}: {:>10}",
|
||||||
style(&result.entity).cyan(),
|
Theme::info().render(&result.entity),
|
||||||
style(&count_str).bold()
|
Theme::bold().render(&count_str)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(breakdown) = &result.state_breakdown {
|
if let Some(breakdown) = &result.state_breakdown {
|
||||||
println!(" opened: {}", format_number(breakdown.opened));
|
println!(" opened: {:>10}", render::format_number(breakdown.opened));
|
||||||
if let Some(merged) = breakdown.merged {
|
if let Some(merged) = breakdown.merged {
|
||||||
println!(" merged: {}", format_number(merged));
|
println!(" merged: {:>10}", render::format_number(merged));
|
||||||
}
|
}
|
||||||
println!(" closed: {}", format_number(breakdown.closed));
|
println!(" closed: {:>10}", render::format_number(breakdown.closed));
|
||||||
if let Some(locked) = breakdown.locked
|
if let Some(locked) = breakdown.locked
|
||||||
&& locked > 0
|
&& locked > 0
|
||||||
{
|
{
|
||||||
println!(" locked: {}", format_number(locked));
|
println!(" locked: {:>10}", render::format_number(locked));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use crate::cli::render;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_number_handles_small_numbers() {
|
fn format_number_handles_small_numbers() {
|
||||||
assert_eq!(format_number(0), "0");
|
assert_eq!(render::format_number(0), "0");
|
||||||
assert_eq!(format_number(1), "1");
|
assert_eq!(render::format_number(1), "1");
|
||||||
assert_eq!(format_number(100), "100");
|
assert_eq!(render::format_number(100), "100");
|
||||||
assert_eq!(format_number(999), "999");
|
assert_eq!(render::format_number(999), "999");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_number_adds_thousands_separators() {
|
fn format_number_adds_thousands_separators() {
|
||||||
assert_eq!(format_number(1000), "1,000");
|
assert_eq!(render::format_number(1000), "1,000");
|
||||||
assert_eq!(format_number(12345), "12,345");
|
assert_eq!(render::format_number(12345), "12,345");
|
||||||
assert_eq!(format_number(1234567), "1,234,567");
|
assert_eq!(render::format_number(1234567), "1,234,567");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::{Icons, Theme};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::core::config::Config;
|
use crate::core::config::Config;
|
||||||
@@ -530,7 +530,7 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_doctor_results(result: &DoctorResult) {
|
pub fn print_doctor_results(result: &DoctorResult) {
|
||||||
println!("\nlore doctor\n");
|
println!();
|
||||||
|
|
||||||
print_check("Config", &result.checks.config.result);
|
print_check("Config", &result.checks.config.result);
|
||||||
print_check("Database", &result.checks.database.result);
|
print_check("Database", &result.checks.database.result);
|
||||||
@@ -539,38 +539,61 @@ pub fn print_doctor_results(result: &DoctorResult) {
|
|||||||
print_check("Ollama", &result.checks.ollama.result);
|
print_check("Ollama", &result.checks.ollama.result);
|
||||||
print_check("Logging", &result.checks.logging.result);
|
print_check("Logging", &result.checks.logging.result);
|
||||||
|
|
||||||
|
// Count statuses
|
||||||
|
let checks = [
|
||||||
|
&result.checks.config.result,
|
||||||
|
&result.checks.database.result,
|
||||||
|
&result.checks.gitlab.result,
|
||||||
|
&result.checks.projects.result,
|
||||||
|
&result.checks.ollama.result,
|
||||||
|
&result.checks.logging.result,
|
||||||
|
];
|
||||||
|
let passed = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == CheckStatus::Ok)
|
||||||
|
.count();
|
||||||
|
let warnings = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == CheckStatus::Warning)
|
||||||
|
.count();
|
||||||
|
let failed = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == CheckStatus::Error)
|
||||||
|
.count();
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
let mut summary_parts = Vec::new();
|
||||||
if result.success {
|
if result.success {
|
||||||
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
|
summary_parts.push(Theme::success().render("Ready"));
|
||||||
if ollama_ok {
|
|
||||||
println!("{}", style("Status: Ready").green());
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"{} {}",
|
|
||||||
style("Status: Ready").green(),
|
|
||||||
style("(lexical search available, semantic search requires Ollama)").yellow()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
println!("{}", style("Status: Not ready").red());
|
summary_parts.push(Theme::error().render("Not ready"));
|
||||||
}
|
}
|
||||||
|
summary_parts.push(format!("{passed} passed"));
|
||||||
|
if warnings > 0 {
|
||||||
|
summary_parts.push(Theme::warning().render(&format!("{warnings} warning")));
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
summary_parts.push(Theme::error().render(&format!("{failed} failed")));
|
||||||
|
}
|
||||||
|
println!(" {}", summary_parts.join(" \u{b7} "));
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_check(name: &str, result: &CheckResult) {
|
fn print_check(name: &str, result: &CheckResult) {
|
||||||
let symbol = match result.status {
|
let icon = match result.status {
|
||||||
CheckStatus::Ok => style("✓").green(),
|
CheckStatus::Ok => Theme::success().render(Icons::success()),
|
||||||
CheckStatus::Warning => style("⚠").yellow(),
|
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
|
||||||
CheckStatus::Error => style("✗").red(),
|
CheckStatus::Error => Theme::error().render(Icons::error()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let message = result.message.as_deref().unwrap_or("");
|
let message = result.message.as_deref().unwrap_or("");
|
||||||
let message_styled = match result.status {
|
let message_styled = match result.status {
|
||||||
CheckStatus::Ok => message.to_string(),
|
CheckStatus::Ok => message.to_string(),
|
||||||
CheckStatus::Warning => style(message).yellow().to_string(),
|
CheckStatus::Warning => Theme::warning().render(message),
|
||||||
CheckStatus::Error => style(message).red().to_string(),
|
CheckStatus::Error => Theme::error().render(message),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(" {symbol} {:<10} {message_styled}", name);
|
println!(" {icon} {:<10} {message_styled}", name);
|
||||||
}
|
}
|
||||||
|
|||||||
650
src/cli/commands/drift.rs
Normal file
650
src/cli/commands/drift.rs
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::cli::render::{Icons, Theme};
|
||||||
|
use crate::cli::robot::RobotMeta;
|
||||||
|
use crate::core::config::Config;
|
||||||
|
use crate::core::db::create_connection;
|
||||||
|
use crate::core::error::{LoreError, Result};
|
||||||
|
use crate::core::paths::get_db_path;
|
||||||
|
use crate::core::project::resolve_project;
|
||||||
|
use crate::core::time::ms_to_iso;
|
||||||
|
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||||
|
use crate::embedding::similarity::cosine_similarity;
|
||||||
|
|
||||||
|
const BATCH_SIZE: usize = 32;
|
||||||
|
const WINDOW_SIZE: usize = 3;
|
||||||
|
const MIN_DESCRIPTION_LEN: usize = 20;
|
||||||
|
const MAX_NOTES: i64 = 200;
|
||||||
|
const TOP_TOPICS: usize = 3;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DriftResponse {
|
||||||
|
pub entity: DriftEntity,
|
||||||
|
pub drift_detected: bool,
|
||||||
|
pub threshold: f32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub drift_point: Option<DriftPoint>,
|
||||||
|
pub drift_topics: Vec<String>,
|
||||||
|
pub similarity_curve: Vec<SimilarityPoint>,
|
||||||
|
pub recommendation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DriftEntity {
|
||||||
|
pub entity_type: String,
|
||||||
|
pub iid: i64,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DriftPoint {
|
||||||
|
pub note_index: usize,
|
||||||
|
pub note_id: i64,
|
||||||
|
pub author: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub similarity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SimilarityPoint {
|
||||||
|
pub note_index: usize,
|
||||||
|
pub similarity: f32,
|
||||||
|
pub author: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal row types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct IssueInfo {
|
||||||
|
id: i64,
|
||||||
|
iid: i64,
|
||||||
|
title: String,
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoteRow {
|
||||||
|
id: i64,
|
||||||
|
body: String,
|
||||||
|
author_username: String,
|
||||||
|
created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn run_drift(
|
||||||
|
config: &Config,
|
||||||
|
entity_type: &str,
|
||||||
|
iid: i64,
|
||||||
|
threshold: f32,
|
||||||
|
project: Option<&str>,
|
||||||
|
) -> Result<DriftResponse> {
|
||||||
|
if entity_type != "issues" {
|
||||||
|
return Err(LoreError::Other(
|
||||||
|
"drift currently supports 'issues' only".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
|
let conn = create_connection(&db_path)?;
|
||||||
|
|
||||||
|
let issue = find_issue(&conn, iid, project)?;
|
||||||
|
|
||||||
|
let description = match &issue.description {
|
||||||
|
Some(d) if d.len() >= MIN_DESCRIPTION_LEN => d.clone(),
|
||||||
|
_ => {
|
||||||
|
return Ok(DriftResponse {
|
||||||
|
entity: DriftEntity {
|
||||||
|
entity_type: entity_type.to_string(),
|
||||||
|
iid: issue.iid,
|
||||||
|
title: issue.title,
|
||||||
|
},
|
||||||
|
drift_detected: false,
|
||||||
|
threshold,
|
||||||
|
drift_point: None,
|
||||||
|
drift_topics: vec![],
|
||||||
|
similarity_curve: vec![],
|
||||||
|
recommendation: "Description too short for drift analysis.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let notes = fetch_notes(&conn, issue.id)?;
|
||||||
|
|
||||||
|
if notes.len() < WINDOW_SIZE {
|
||||||
|
return Ok(DriftResponse {
|
||||||
|
entity: DriftEntity {
|
||||||
|
entity_type: entity_type.to_string(),
|
||||||
|
iid: issue.iid,
|
||||||
|
title: issue.title,
|
||||||
|
},
|
||||||
|
drift_detected: false,
|
||||||
|
threshold,
|
||||||
|
drift_point: None,
|
||||||
|
drift_topics: vec![],
|
||||||
|
similarity_curve: vec![],
|
||||||
|
recommendation: format!(
|
||||||
|
"Only {} note(s) found; need at least {} for drift detection.",
|
||||||
|
notes.len(),
|
||||||
|
WINDOW_SIZE
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build texts to embed: description first, then each note body.
|
||||||
|
let mut texts: Vec<String> = Vec::with_capacity(1 + notes.len());
|
||||||
|
texts.push(description.clone());
|
||||||
|
for note in ¬es {
|
||||||
|
texts.push(note.body.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let embeddings = embed_texts(config, &texts).await?;
|
||||||
|
|
||||||
|
let desc_embedding = &embeddings[0];
|
||||||
|
let note_embeddings = &embeddings[1..];
|
||||||
|
|
||||||
|
// Build similarity curve.
|
||||||
|
let similarity_curve: Vec<SimilarityPoint> = note_embeddings
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, emb)| SimilarityPoint {
|
||||||
|
note_index: i,
|
||||||
|
similarity: cosine_similarity(desc_embedding, emb),
|
||||||
|
author: notes[i].author_username.clone(),
|
||||||
|
created_at: ms_to_iso(notes[i].created_at),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Detect drift via sliding window.
|
||||||
|
let (drift_detected, drift_point) = detect_drift(&similarity_curve, ¬es, threshold);
|
||||||
|
|
||||||
|
// Extract drift topics.
|
||||||
|
let drift_topics = if drift_detected {
|
||||||
|
let drift_idx = drift_point.as_ref().map_or(0, |dp| dp.note_index);
|
||||||
|
extract_drift_topics(&description, ¬es, drift_idx)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
let recommendation = if drift_detected {
|
||||||
|
let dp = drift_point.as_ref().unwrap();
|
||||||
|
format!(
|
||||||
|
"Discussion drifted at note {} by @{} (similarity {:.2}). Consider splitting into a new issue.",
|
||||||
|
dp.note_index, dp.author, dp.similarity
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"Discussion remains on topic.".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DriftResponse {
|
||||||
|
entity: DriftEntity {
|
||||||
|
entity_type: entity_type.to_string(),
|
||||||
|
iid: issue.iid,
|
||||||
|
title: issue.title,
|
||||||
|
},
|
||||||
|
drift_detected,
|
||||||
|
threshold,
|
||||||
|
drift_point,
|
||||||
|
drift_topics,
|
||||||
|
similarity_curve,
|
||||||
|
recommendation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn find_issue(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
iid: i64,
|
||||||
|
project_filter: Option<&str>,
|
||||||
|
) -> Result<IssueInfo> {
|
||||||
|
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
|
||||||
|
Some(project) => {
|
||||||
|
let project_id = resolve_project(conn, project)?;
|
||||||
|
(
|
||||||
|
"SELECT i.id, i.iid, i.title, i.description
|
||||||
|
FROM issues i
|
||||||
|
WHERE i.iid = ? AND i.project_id = ?",
|
||||||
|
vec![Box::new(iid), Box::new(project_id)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
"SELECT i.id, i.iid, i.title, i.description
|
||||||
|
FROM issues i
|
||||||
|
WHERE i.iid = ?",
|
||||||
|
vec![Box::new(iid)],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(sql)?;
|
||||||
|
let rows: Vec<IssueInfo> = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
Ok(IssueInfo {
|
||||||
|
id: row.get(0)?,
|
||||||
|
iid: row.get(1)?,
|
||||||
|
title: row.get(2)?,
|
||||||
|
description: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
match rows.len() {
|
||||||
|
0 => Err(LoreError::NotFound(format!("Issue #{iid} not found"))),
|
||||||
|
1 => Ok(rows.into_iter().next().unwrap()),
|
||||||
|
_ => Err(LoreError::Ambiguous(format!(
|
||||||
|
"Issue #{iid} exists in multiple projects. Use --project to specify."
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_notes(conn: &rusqlite::Connection, issue_id: i64) -> Result<Vec<NoteRow>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT n.id, n.body, n.author_username, n.created_at
|
||||||
|
FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
WHERE d.issue_id = ?
|
||||||
|
AND n.is_system = 0
|
||||||
|
AND LENGTH(n.body) >= 20
|
||||||
|
ORDER BY n.created_at ASC
|
||||||
|
LIMIT ?",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let notes: Vec<NoteRow> = stmt
|
||||||
|
.query_map(rusqlite::params![issue_id, MAX_NOTES], |row| {
|
||||||
|
Ok(NoteRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
body: row.get(1)?,
|
||||||
|
author_username: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Embedding helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn embed_texts(config: &Config, texts: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||||
|
let ollama = OllamaClient::new(OllamaConfig {
|
||||||
|
base_url: config.embedding.base_url.clone(),
|
||||||
|
model: config.embedding.model.clone(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut all_embeddings: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
|
||||||
|
|
||||||
|
for chunk in texts.chunks(BATCH_SIZE) {
|
||||||
|
let refs: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
|
||||||
|
let batch_result = ollama.embed_batch(&refs).await?;
|
||||||
|
all_embeddings.extend(batch_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_embeddings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drift detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn detect_drift(
|
||||||
|
curve: &[SimilarityPoint],
|
||||||
|
notes: &[NoteRow],
|
||||||
|
threshold: f32,
|
||||||
|
) -> (bool, Option<DriftPoint>) {
|
||||||
|
if curve.len() < WINDOW_SIZE {
|
||||||
|
return (false, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..=curve.len() - WINDOW_SIZE {
|
||||||
|
let window_avg: f32 = curve[i..i + WINDOW_SIZE]
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.similarity)
|
||||||
|
.sum::<f32>()
|
||||||
|
/ WINDOW_SIZE as f32;
|
||||||
|
|
||||||
|
if window_avg < threshold {
|
||||||
|
return (
|
||||||
|
true,
|
||||||
|
Some(DriftPoint {
|
||||||
|
note_index: i,
|
||||||
|
note_id: notes[i].id,
|
||||||
|
author: notes[i].author_username.clone(),
|
||||||
|
created_at: ms_to_iso(notes[i].created_at),
|
||||||
|
similarity: curve[i].similarity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(false, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Topic extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static STOPWORDS: LazyLock<std::collections::HashSet<&'static str>> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
|
||||||
|
"do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can",
|
||||||
|
"need", "dare", "ought", "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
|
||||||
|
"as", "into", "through", "during", "before", "after", "above", "below", "between", "out",
|
||||||
|
"off", "over", "under", "again", "further", "then", "once", "here", "there", "when",
|
||||||
|
"where", "why", "how", "all", "each", "every", "both", "few", "more", "most", "other",
|
||||||
|
"some", "such", "no", "not", "only", "own", "same", "so", "than", "too", "very", "just",
|
||||||
|
"because", "but", "and", "or", "if", "while", "about", "up", "it", "its", "this", "that",
|
||||||
|
"these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "him", "his", "she",
|
||||||
|
"her", "they", "them", "their", "what", "which", "who", "whom", "also", "like", "get",
|
||||||
|
"got", "think", "know", "see", "make", "go", "one", "two", "new", "way",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
|
let cleaned = strip_markdown(text);
|
||||||
|
cleaned
|
||||||
|
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||||
|
.filter(|w| w.len() >= 3)
|
||||||
|
.map(|w| w.to_lowercase())
|
||||||
|
.filter(|w| !STOPWORDS.contains(w.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_drift_topics(description: &str, notes: &[NoteRow], drift_idx: usize) -> Vec<String> {
|
||||||
|
let desc_terms: std::collections::HashSet<String> = tokenize(description).into_iter().collect();
|
||||||
|
|
||||||
|
let mut freq: HashMap<String, usize> = HashMap::new();
|
||||||
|
for note in notes.iter().skip(drift_idx) {
|
||||||
|
for term in tokenize(¬e.body) {
|
||||||
|
if !desc_terms.contains(&term) {
|
||||||
|
*freq.entry(term).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted: Vec<(String, usize)> = freq.into_iter().collect();
|
||||||
|
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
sorted
|
||||||
|
.into_iter()
|
||||||
|
.take(TOP_TOPICS)
|
||||||
|
.map(|(t, _)| t)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Markdown stripping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static RE_FENCED_CODE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"(?s)```[^\n]*\n.*?```").unwrap());
|
||||||
|
static RE_INLINE_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]+`").unwrap());
|
||||||
|
static RE_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap());
|
||||||
|
static RE_BLOCKQUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^>\s?").unwrap());
|
||||||
|
static RE_HTML_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||||
|
|
||||||
|
fn strip_markdown(text: &str) -> String {
|
||||||
|
let text = RE_FENCED_CODE.replace_all(text, "");
|
||||||
|
let text = RE_INLINE_CODE.replace_all(&text, "");
|
||||||
|
let text = RE_LINK.replace_all(&text, "$1");
|
||||||
|
let text = RE_BLOCKQUOTE.replace_all(&text, "");
|
||||||
|
let text = RE_HTML_TAG.replace_all(&text, "");
|
||||||
|
text.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Printers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn print_drift_human(response: &DriftResponse) {
|
||||||
|
let header = format!(
|
||||||
|
"Drift Analysis: {} #{}",
|
||||||
|
response.entity.entity_type, response.entity.iid
|
||||||
|
);
|
||||||
|
println!("{}", Theme::bold().render(&header));
|
||||||
|
println!("{}", "-".repeat(header.len().min(60)));
|
||||||
|
println!("Title: {}", response.entity.title);
|
||||||
|
println!("Threshold: {:.2}", response.threshold);
|
||||||
|
println!("Notes: {}", response.similarity_curve.len());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if response.drift_detected {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
Theme::error().render(Icons::error()),
|
||||||
|
Theme::error().bold().render("DRIFT DETECTED")
|
||||||
|
);
|
||||||
|
if let Some(dp) = &response.drift_point {
|
||||||
|
println!(
|
||||||
|
" At note #{} by @{} ({}) - similarity {:.2}",
|
||||||
|
dp.note_index, dp.author, dp.created_at, dp.similarity
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !response.drift_topics.is_empty() {
|
||||||
|
println!(" Topics: {}", response.drift_topics.join(", "));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
Theme::success().render(Icons::success()),
|
||||||
|
Theme::success().render("No drift detected")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", response.recommendation);
|
||||||
|
|
||||||
|
if !response.similarity_curve.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!("{}", Theme::bold().render("Similarity Curve:"));
|
||||||
|
for pt in &response.similarity_curve {
|
||||||
|
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
|
||||||
|
let bar: String = "\u{2588}".repeat(bar_len);
|
||||||
|
println!(
|
||||||
|
" {:>3} {:.2} {} @{}",
|
||||||
|
pt.note_index, pt.similarity, bar, pt.author
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_drift_json(response: &DriftResponse, elapsed_ms: u64) {
|
||||||
|
let meta = RobotMeta { elapsed_ms };
|
||||||
|
let output = serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"data": response,
|
||||||
|
"meta": meta,
|
||||||
|
});
|
||||||
|
match serde_json::to_string(&output) {
|
||||||
|
Ok(json) => println!("{json}"),
|
||||||
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_drift_when_divergent() {
|
||||||
|
let notes: Vec<NoteRow> = (0..6)
|
||||||
|
.map(|i| NoteRow {
|
||||||
|
id: i as i64,
|
||||||
|
body: format!("note {i}"),
|
||||||
|
author_username: "user".to_string(),
|
||||||
|
created_at: 1000 + i as i64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let curve: Vec<SimilarityPoint> = [0.9, 0.85, 0.8, 0.25, 0.2, 0.15]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &sim)| SimilarityPoint {
|
||||||
|
note_index: i,
|
||||||
|
similarity: sim,
|
||||||
|
author: "user".to_string(),
|
||||||
|
created_at: ms_to_iso(1000 + i as i64),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (detected, point) = detect_drift(&curve, ¬es, 0.4);
|
||||||
|
assert!(detected);
|
||||||
|
assert!(point.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_drift_consistent() {
|
||||||
|
let notes: Vec<NoteRow> = (0..5)
|
||||||
|
.map(|i| NoteRow {
|
||||||
|
id: i as i64,
|
||||||
|
body: format!("note {i}"),
|
||||||
|
author_username: "user".to_string(),
|
||||||
|
created_at: 1000 + i as i64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let curve: Vec<SimilarityPoint> = [0.85, 0.8, 0.75, 0.7, 0.65]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &sim)| SimilarityPoint {
|
||||||
|
note_index: i,
|
||||||
|
similarity: sim,
|
||||||
|
author: "user".to_string(),
|
||||||
|
created_at: ms_to_iso(1000 + i as i64),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (detected, _) = detect_drift(&curve, ¬es, 0.4);
|
||||||
|
assert!(!detected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_drift_point_is_first_divergent() {
|
||||||
|
let notes: Vec<NoteRow> = (0..5)
|
||||||
|
.map(|i| NoteRow {
|
||||||
|
id: (i * 10) as i64,
|
||||||
|
body: format!("note {i}"),
|
||||||
|
author_username: format!("user{i}"),
|
||||||
|
created_at: 1000 + i as i64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Window of 3: indices [0,1,2] avg=0.83, [1,2,3] avg=0.55, [2,3,4] avg=0.23
|
||||||
|
let curve: Vec<SimilarityPoint> = [0.9, 0.8, 0.8, 0.05, 0.05]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &sim)| SimilarityPoint {
|
||||||
|
note_index: i,
|
||||||
|
similarity: sim,
|
||||||
|
author: format!("user{i}"),
|
||||||
|
created_at: ms_to_iso(1000 + i as i64),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (detected, point) = detect_drift(&curve, ¬es, 0.4);
|
||||||
|
assert!(detected);
|
||||||
|
let dp = point.unwrap();
|
||||||
|
// Window [2,3,4] avg = (0.8+0.05+0.05)/3 = 0.3 < 0.4
|
||||||
|
// But [1,2,3] avg = (0.8+0.8+0.05)/3 = 0.55 >= 0.4, so first failing is index 2
|
||||||
|
assert_eq!(dp.note_index, 2);
|
||||||
|
assert_eq!(dp.note_id, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_drift_topics_excludes_description_terms() {
|
||||||
|
let description = "We need to fix the authentication flow for login users";
|
||||||
|
let notes = vec![
|
||||||
|
NoteRow {
|
||||||
|
id: 1,
|
||||||
|
body: "The database migration script is broken and needs postgres update"
|
||||||
|
.to_string(),
|
||||||
|
author_username: "dev".to_string(),
|
||||||
|
created_at: 1000,
|
||||||
|
},
|
||||||
|
NoteRow {
|
||||||
|
id: 2,
|
||||||
|
body: "The database connection pool also has migration issues with postgres"
|
||||||
|
.to_string(),
|
||||||
|
author_username: "dev".to_string(),
|
||||||
|
created_at: 2000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let topics = extract_drift_topics(description, ¬es, 0);
|
||||||
|
// "database", "migration", "postgres" should appear; "fix" should not (it's in description)
|
||||||
|
assert!(!topics.is_empty());
|
||||||
|
for t in &topics {
|
||||||
|
assert_ne!(t, "fix");
|
||||||
|
assert_ne!(t, "authentication");
|
||||||
|
assert_ne!(t, "login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_markdown_code_blocks() {
|
||||||
|
let input = "Before\n```rust\nfn main() {}\n```\nAfter";
|
||||||
|
let result = strip_markdown(input);
|
||||||
|
assert!(!result.contains("fn main"));
|
||||||
|
assert!(result.contains("Before"));
|
||||||
|
assert!(result.contains("After"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_markdown_preserves_text() {
|
||||||
|
let input = "Check [this link](https://example.com) and `inline code` for details";
|
||||||
|
let result = strip_markdown(input);
|
||||||
|
assert!(result.contains("this link"));
|
||||||
|
assert!(!result.contains("https://example.com"));
|
||||||
|
assert!(!result.contains("inline code"));
|
||||||
|
assert!(result.contains("details"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_too_few_notes() {
|
||||||
|
let notes: Vec<NoteRow> = (0..2)
|
||||||
|
.map(|i| NoteRow {
|
||||||
|
id: i as i64,
|
||||||
|
body: format!("note {i}"),
|
||||||
|
author_username: "user".to_string(),
|
||||||
|
created_at: 1000 + i as i64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let curve: Vec<SimilarityPoint> = [0.1, 0.1]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &sim)| SimilarityPoint {
|
||||||
|
note_index: i,
|
||||||
|
similarity: sim,
|
||||||
|
author: "user".to_string(),
|
||||||
|
created_at: ms_to_iso(1000 + i as i64),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (detected, _) = detect_drift(&curve, ¬es, 0.4);
|
||||||
|
assert!(!detected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::Theme;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
@@ -96,16 +96,31 @@ pub async fn run_embed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_embed(result: &EmbedCommandResult) {
|
pub fn print_embed(result: &EmbedCommandResult) {
|
||||||
println!("{} Embedding complete", style("done").green().bold(),);
|
if result.docs_embedded == 0 && result.failed == 0 && result.skipped == 0 {
|
||||||
|
println!(
|
||||||
|
"\n {} nothing to embed",
|
||||||
|
Theme::success().bold().render("Embedding")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" Embedded: {} documents ({} chunks)",
|
"\n {} {} documents ({} chunks)",
|
||||||
result.docs_embedded, result.chunks_embedded
|
Theme::success().bold().render("Embedded"),
|
||||||
|
Theme::bold().render(&result.docs_embedded.to_string()),
|
||||||
|
result.chunks_embedded
|
||||||
);
|
);
|
||||||
if result.failed > 0 {
|
if result.failed > 0 {
|
||||||
println!(" Failed: {}", style(result.failed).red());
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::error().render(&format!("{} failed", result.failed))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if result.skipped > 0 {
|
if result.skipped > 0 {
|
||||||
println!(" Skipped: {}", result.skipped);
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::dim().render(&format!("{} skipped", result.skipped))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
334
src/cli/commands/file_history.rs
Normal file
334
src/cli/commands/file_history.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::Config;
|
||||||
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
|
use crate::core::db::create_connection;
|
||||||
|
use crate::core::error::Result;
|
||||||
|
use crate::core::file_history::resolve_rename_chain;
|
||||||
|
use crate::core::paths::get_db_path;
|
||||||
|
use crate::core::project::resolve_project;
|
||||||
|
use crate::core::time::ms_to_iso;
|
||||||
|
|
||||||
|
/// Maximum rename chain BFS depth.
|
||||||
|
const MAX_RENAME_HOPS: usize = 10;
|
||||||
|
|
||||||
|
/// A single MR that touched the file.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileHistoryMr {
|
||||||
|
pub iid: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub state: String,
|
||||||
|
pub author_username: String,
|
||||||
|
pub change_type: String,
|
||||||
|
pub merged_at_iso: Option<String>,
|
||||||
|
pub updated_at_iso: String,
|
||||||
|
pub merge_commit_sha: Option<String>,
|
||||||
|
pub web_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A DiffNote discussion snippet on the file.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileDiscussion {
|
||||||
|
pub discussion_id: String,
|
||||||
|
pub author_username: String,
|
||||||
|
pub body_snippet: String,
|
||||||
|
pub path: String,
|
||||||
|
pub created_at_iso: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full result of a file-history query.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileHistoryResult {
|
||||||
|
pub path: String,
|
||||||
|
pub rename_chain: Vec<String>,
|
||||||
|
pub renames_followed: bool,
|
||||||
|
pub merge_requests: Vec<FileHistoryMr>,
|
||||||
|
pub discussions: Vec<FileDiscussion>,
|
||||||
|
pub total_mrs: usize,
|
||||||
|
pub paths_searched: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the file-history query.
|
||||||
|
pub fn run_file_history(
|
||||||
|
config: &Config,
|
||||||
|
path: &str,
|
||||||
|
project: Option<&str>,
|
||||||
|
no_follow_renames: bool,
|
||||||
|
merged_only: bool,
|
||||||
|
include_discussions: bool,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<FileHistoryResult> {
|
||||||
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
|
let conn = create_connection(&db_path)?;
|
||||||
|
|
||||||
|
let project_id = project.map(|p| resolve_project(&conn, p)).transpose()?;
|
||||||
|
|
||||||
|
// Resolve rename chain unless disabled
|
||||||
|
let (all_paths, renames_followed) = if no_follow_renames {
|
||||||
|
(vec![path.to_string()], false)
|
||||||
|
} else if let Some(pid) = project_id {
|
||||||
|
let chain = resolve_rename_chain(&conn, pid, path, MAX_RENAME_HOPS)?;
|
||||||
|
let followed = chain.len() > 1;
|
||||||
|
(chain, followed)
|
||||||
|
} else {
|
||||||
|
// Without a project scope, can't resolve renames (need project_id)
|
||||||
|
(vec![path.to_string()], false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let paths_searched = all_paths.len();
|
||||||
|
|
||||||
|
// Build placeholders for IN clause
|
||||||
|
let placeholders: Vec<String> = (0..all_paths.len())
|
||||||
|
.map(|i| format!("?{}", i + 2))
|
||||||
|
.collect();
|
||||||
|
let in_clause = placeholders.join(", ");
|
||||||
|
|
||||||
|
let merged_filter = if merged_only {
|
||||||
|
" AND mr.state = 'merged'"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_filter = if project_id.is_some() {
|
||||||
|
"AND mfc.project_id = ?1"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT DISTINCT \
|
||||||
|
mr.iid, mr.title, mr.state, mr.author_username, \
|
||||||
|
mfc.change_type, mr.merged_at, mr.updated_at, mr.merge_commit_sha, mr.web_url \
|
||||||
|
FROM mr_file_changes mfc \
|
||||||
|
JOIN merge_requests mr ON mr.id = mfc.merge_request_id \
|
||||||
|
WHERE mfc.new_path IN ({in_clause}) {project_filter} {merged_filter} \
|
||||||
|
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \
|
||||||
|
LIMIT ?{}",
|
||||||
|
all_paths.len() + 2
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
|
||||||
|
// Bind parameters: ?1 = project_id (or 0 placeholder), ?2..?N+1 = paths, ?N+2 = limit
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
|
params.push(Box::new(project_id.unwrap_or(0)));
|
||||||
|
for p in &all_paths {
|
||||||
|
params.push(Box::new(p.clone()));
|
||||||
|
}
|
||||||
|
params.push(Box::new(limit as i64));
|
||||||
|
|
||||||
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
|
let merge_requests: Vec<FileHistoryMr> = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
let merged_at: Option<i64> = row.get(5)?;
|
||||||
|
let updated_at: i64 = row.get(6)?;
|
||||||
|
Ok(FileHistoryMr {
|
||||||
|
iid: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
state: row.get(2)?,
|
||||||
|
author_username: row.get(3)?,
|
||||||
|
change_type: row.get(4)?,
|
||||||
|
merged_at_iso: merged_at.map(ms_to_iso),
|
||||||
|
updated_at_iso: ms_to_iso(updated_at),
|
||||||
|
merge_commit_sha: row.get(7)?,
|
||||||
|
web_url: row.get(8)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_mrs = merge_requests.len();
|
||||||
|
|
||||||
|
// Optionally fetch DiffNote discussions on this file
|
||||||
|
let discussions = if include_discussions && !merge_requests.is_empty() {
|
||||||
|
fetch_file_discussions(&conn, &all_paths, project_id)?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FileHistoryResult {
|
||||||
|
path: path.to_string(),
|
||||||
|
rename_chain: all_paths,
|
||||||
|
renames_followed,
|
||||||
|
merge_requests,
|
||||||
|
discussions,
|
||||||
|
total_mrs,
|
||||||
|
paths_searched,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch DiffNote discussions that reference the given file paths.
|
||||||
|
fn fetch_file_discussions(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
paths: &[String],
|
||||||
|
project_id: Option<i64>,
|
||||||
|
) -> Result<Vec<FileDiscussion>> {
|
||||||
|
let placeholders: Vec<String> = (0..paths.len()).map(|i| format!("?{}", i + 2)).collect();
|
||||||
|
let in_clause = placeholders.join(", ");
|
||||||
|
|
||||||
|
let project_filter = if project_id.is_some() {
|
||||||
|
"AND d.project_id = ?1"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT d.gitlab_discussion_id, n.author_username, n.body, n.position_new_path, n.created_at \
|
||||||
|
FROM notes n \
|
||||||
|
JOIN discussions d ON d.id = n.discussion_id \
|
||||||
|
WHERE n.position_new_path IN ({in_clause}) {project_filter} \
|
||||||
|
AND n.is_system = 0 \
|
||||||
|
ORDER BY n.created_at DESC \
|
||||||
|
LIMIT 50"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
|
params.push(Box::new(project_id.unwrap_or(0)));
|
||||||
|
for p in paths {
|
||||||
|
params.push(Box::new(p.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
|
let discussions: Vec<FileDiscussion> = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
let body: String = row.get(2)?;
|
||||||
|
let snippet = if body.len() > 200 {
|
||||||
|
format!("{}...", &body[..body.floor_char_boundary(200)])
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
};
|
||||||
|
let created_at: i64 = row.get(4)?;
|
||||||
|
Ok(FileDiscussion {
|
||||||
|
discussion_id: row.get(0)?,
|
||||||
|
author_username: row.get(1)?,
|
||||||
|
body_snippet: snippet,
|
||||||
|
path: row.get(3)?,
|
||||||
|
created_at_iso: ms_to_iso(created_at),
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(discussions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Human output ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn print_file_history(result: &FileHistoryResult) {
|
||||||
|
// Header
|
||||||
|
let paths_info = if result.paths_searched > 1 {
|
||||||
|
format!(
|
||||||
|
" (via {} paths, {} MRs)",
|
||||||
|
result.paths_searched, result.total_mrs
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(" ({} MRs)", result.total_mrs)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
Theme::bold().render(&format!("File History: {}{}", result.path, paths_info))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rename chain
|
||||||
|
if result.renames_followed && result.rename_chain.len() > 1 {
|
||||||
|
let chain_str: Vec<&str> = result.rename_chain.iter().map(String::as_str).collect();
|
||||||
|
println!(
|
||||||
|
" Rename chain: {}",
|
||||||
|
Theme::dim().render(&chain_str.join(" -> "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.merge_requests.is_empty() {
|
||||||
|
println!(
|
||||||
|
"\n {} {}",
|
||||||
|
Icons::info(),
|
||||||
|
Theme::dim().render("No merge requests found touching this file.")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::dim().render("Hint: Run 'lore sync' to fetch MR file changes.")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for mr in &result.merge_requests {
|
||||||
|
let (icon, state_style) = match mr.state.as_str() {
|
||||||
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||||
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||||
|
"closed" => (Icons::mr_closed(), Theme::warning()),
|
||||||
|
_ => (Icons::mr_opened(), Theme::dim()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let date = mr
|
||||||
|
.merged_at_iso
|
||||||
|
.as_deref()
|
||||||
|
.or(Some(mr.updated_at_iso.as_str()))
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('T')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} @{} {} {}",
|
||||||
|
icon,
|
||||||
|
Theme::accent().render(&format!("!{}", mr.iid)),
|
||||||
|
render::truncate(&mr.title, 50),
|
||||||
|
state_style.render(&mr.state),
|
||||||
|
mr.author_username,
|
||||||
|
date,
|
||||||
|
Theme::dim().render(&mr.change_type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discussions
|
||||||
|
if !result.discussions.is_empty() {
|
||||||
|
println!(
|
||||||
|
"\n {} File discussions ({}):",
|
||||||
|
Icons::note(),
|
||||||
|
result.discussions.len()
|
||||||
|
);
|
||||||
|
for d in &result.discussions {
|
||||||
|
let date = d.created_at_iso.split('T').next().unwrap_or("");
|
||||||
|
println!(
|
||||||
|
" @{} ({}) [{}]: {}",
|
||||||
|
d.author_username,
|
||||||
|
date,
|
||||||
|
Theme::dim().render(&d.path),
|
||||||
|
d.body_snippet
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Robot (JSON) output ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
|
||||||
|
let output = serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"path": result.path,
|
||||||
|
"rename_chain": if result.renames_followed { Some(&result.rename_chain) } else { None },
|
||||||
|
"merge_requests": result.merge_requests,
|
||||||
|
"discussions": if result.discussions.is_empty() { None } else { Some(&result.discussions) },
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
"total_mrs": result.total_mrs,
|
||||||
|
"renames_followed": result.renames_followed,
|
||||||
|
"paths_searched": result.paths_searched,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::Theme;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -39,6 +39,7 @@ pub fn run_generate_docs(
|
|||||||
result.seeded += seed_dirty(&conn, SourceType::Issue, project_filter)?;
|
result.seeded += seed_dirty(&conn, SourceType::Issue, project_filter)?;
|
||||||
result.seeded += seed_dirty(&conn, SourceType::MergeRequest, project_filter)?;
|
result.seeded += seed_dirty(&conn, SourceType::MergeRequest, project_filter)?;
|
||||||
result.seeded += seed_dirty(&conn, SourceType::Discussion, project_filter)?;
|
result.seeded += seed_dirty(&conn, SourceType::Discussion, project_filter)?;
|
||||||
|
result.seeded += seed_dirty_notes(&conn, project_filter)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let regen =
|
let regen =
|
||||||
@@ -67,6 +68,10 @@ fn seed_dirty(
|
|||||||
SourceType::Issue => "issues",
|
SourceType::Issue => "issues",
|
||||||
SourceType::MergeRequest => "merge_requests",
|
SourceType::MergeRequest => "merge_requests",
|
||||||
SourceType::Discussion => "discussions",
|
SourceType::Discussion => "discussions",
|
||||||
|
SourceType::Note => {
|
||||||
|
// NOTE-2E will implement seed_dirty_notes separately (needs is_system filter)
|
||||||
|
unreachable!("Note seeding handled by seed_dirty_notes, not seed_dirty")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let type_str = source_type.as_str();
|
let type_str = source_type.as_str();
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
@@ -125,25 +130,95 @@ fn seed_dirty(
|
|||||||
Ok(total_seeded)
|
Ok(total_seeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seed_dirty_notes(conn: &Connection, project_filter: Option<&str>) -> Result<usize> {
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
let mut total_seeded: usize = 0;
|
||||||
|
let mut last_id: i64 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let inserted = if let Some(project) = project_filter {
|
||||||
|
let project_id = resolve_project(conn, project)?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dirty_sources (source_type, source_id, queued_at, attempt_count, last_attempt_at, last_error, next_attempt_at)
|
||||||
|
SELECT 'note', id, ?1, 0, NULL, NULL, NULL
|
||||||
|
FROM notes WHERE id > ?2 AND project_id = ?3 AND is_system = 0 ORDER BY id LIMIT ?4
|
||||||
|
ON CONFLICT(source_type, source_id) DO NOTHING",
|
||||||
|
rusqlite::params![now, last_id, project_id, FULL_MODE_CHUNK_SIZE],
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dirty_sources (source_type, source_id, queued_at, attempt_count, last_attempt_at, last_error, next_attempt_at)
|
||||||
|
SELECT 'note', id, ?1, 0, NULL, NULL, NULL
|
||||||
|
FROM notes WHERE id > ?2 AND is_system = 0 ORDER BY id LIMIT ?3
|
||||||
|
ON CONFLICT(source_type, source_id) DO NOTHING",
|
||||||
|
rusqlite::params![now, last_id, FULL_MODE_CHUNK_SIZE],
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if inserted == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_id: i64 = conn.query_row(
|
||||||
|
"SELECT MAX(id) FROM (SELECT id FROM notes WHERE id > ?1 AND is_system = 0 ORDER BY id LIMIT ?2)",
|
||||||
|
rusqlite::params![last_id, FULL_MODE_CHUNK_SIZE],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
total_seeded += inserted;
|
||||||
|
last_id = max_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
source_type = "note",
|
||||||
|
seeded = total_seeded,
|
||||||
|
"Seeded dirty_sources"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(total_seeded)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_generate_docs(result: &GenerateDocsResult) {
|
pub fn print_generate_docs(result: &GenerateDocsResult) {
|
||||||
let mode = if result.full_mode {
|
let mode = if result.full_mode {
|
||||||
"full"
|
"full"
|
||||||
} else {
|
} else {
|
||||||
"incremental"
|
"incremental"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if result.regenerated == 0 && result.errored == 0 {
|
||||||
|
println!(
|
||||||
|
"\n {} no documents to update ({})",
|
||||||
|
Theme::success().bold().render("Docs"),
|
||||||
|
mode
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headline
|
||||||
println!(
|
println!(
|
||||||
"{} Document generation complete ({})",
|
"\n {} {} documents ({})",
|
||||||
style("done").green().bold(),
|
Theme::success().bold().render("Generated"),
|
||||||
|
Theme::bold().render(&result.regenerated.to_string()),
|
||||||
mode
|
mode
|
||||||
);
|
);
|
||||||
|
|
||||||
if result.full_mode {
|
// Detail line: compact middle-dot format, zero-suppressed
|
||||||
println!(" Seeded: {}", result.seeded);
|
let mut details: Vec<String> = Vec::new();
|
||||||
|
if result.full_mode && result.seeded > 0 {
|
||||||
|
details.push(format!("{} seeded", result.seeded));
|
||||||
|
}
|
||||||
|
if result.unchanged > 0 {
|
||||||
|
details.push(format!("{} unchanged", result.unchanged));
|
||||||
|
}
|
||||||
|
if !details.is_empty() {
|
||||||
|
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||||
}
|
}
|
||||||
println!(" Regenerated: {}", result.regenerated);
|
|
||||||
println!(" Unchanged: {}", result.unchanged);
|
|
||||||
if result.errored > 0 {
|
if result.errored > 0 {
|
||||||
println!(" Errored: {}", style(result.errored).red());
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::error().render(&format!("{} errored", result.errored))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,3 +261,81 @@ pub fn print_generate_docs_json(result: &GenerateDocsResult, elapsed_ms: u64) {
|
|||||||
};
|
};
|
||||||
println!("{}", serde_json::to_string(&output).unwrap());
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::core::db::{create_connection, run_migrations};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn setup_db() -> Connection {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (1, 100, 'group/project', 'https://gitlab.com/group/project')",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 1, 'Test', 'opened', 1000, 2000, 3000)",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_note(conn: &Connection, id: i64, gitlab_id: i64, is_system: bool) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'alice', 'note body', 1000, 2000, 3000, ?3)",
|
||||||
|
rusqlite::params![id, gitlab_id, is_system as i32],
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_seed_includes_notes() {
|
||||||
|
let conn = setup_db();
|
||||||
|
insert_note(&conn, 1, 101, false);
|
||||||
|
insert_note(&conn, 2, 102, false);
|
||||||
|
insert_note(&conn, 3, 103, false);
|
||||||
|
insert_note(&conn, 4, 104, true); // system note — should be excluded
|
||||||
|
|
||||||
|
let seeded = seed_dirty_notes(&conn, None).unwrap();
|
||||||
|
assert_eq!(seeded, 3);
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_note_document_count_stable_after_second_generate_docs_full() {
|
||||||
|
let conn = setup_db();
|
||||||
|
insert_note(&conn, 1, 101, false);
|
||||||
|
insert_note(&conn, 2, 102, false);
|
||||||
|
|
||||||
|
let first = seed_dirty_notes(&conn, None).unwrap();
|
||||||
|
assert_eq!(first, 2);
|
||||||
|
|
||||||
|
// Second run should be idempotent (ON CONFLICT DO NOTHING)
|
||||||
|
let second = seed_dirty_notes(&conn, None).unwrap();
|
||||||
|
assert_eq!(second, 0);
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use console::style;
|
use crate::cli::render::Theme;
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -46,10 +46,27 @@ pub struct IngestResult {
|
|||||||
pub mr_diffs_failed: usize,
|
pub mr_diffs_failed: usize,
|
||||||
pub status_enrichment_errors: usize,
|
pub status_enrichment_errors: usize,
|
||||||
pub status_enrichment_projects: Vec<ProjectStatusEnrichment>,
|
pub status_enrichment_projects: Vec<ProjectStatusEnrichment>,
|
||||||
|
pub project_summaries: Vec<ProjectSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-project summary for display in stage completion sub-rows.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ProjectSummary {
|
||||||
|
pub path: String,
|
||||||
|
pub items_upserted: usize,
|
||||||
|
pub discussions_synced: usize,
|
||||||
|
pub events_fetched: usize,
|
||||||
|
pub events_failed: usize,
|
||||||
|
pub statuses_enriched: usize,
|
||||||
|
pub statuses_seen: usize,
|
||||||
|
pub status_errors: usize,
|
||||||
|
pub mr_diffs_fetched: usize,
|
||||||
|
pub mr_diffs_failed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-project status enrichment result, collected during ingestion.
|
/// Per-project status enrichment result, collected during ingestion.
|
||||||
pub struct ProjectStatusEnrichment {
|
pub struct ProjectStatusEnrichment {
|
||||||
|
pub path: String,
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
pub seen: usize,
|
pub seen: usize,
|
||||||
@@ -293,7 +310,7 @@ async fn run_ingest_inner(
|
|||||||
if display.show_text {
|
if display.show_text {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style("Full sync: resetting cursors to fetch all data...").yellow()
|
Theme::warning().render("Full sync: resetting cursors to fetch all data...")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (local_project_id, _, path) in &projects {
|
for (local_project_id, _, path) in &projects {
|
||||||
@@ -341,7 +358,10 @@ async fn run_ingest_inner(
|
|||||||
"merge requests"
|
"merge requests"
|
||||||
};
|
};
|
||||||
if display.show_text {
|
if display.show_text {
|
||||||
println!("{}", style(format!("Ingesting {type_label}...")).blue());
|
println!(
|
||||||
|
"{}",
|
||||||
|
Theme::info().render(&format!("Ingesting {type_label}..."))
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,11 +405,11 @@ async fn run_ingest_inner(
|
|||||||
let s = multi.add(ProgressBar::new_spinner());
|
let s = multi.add(ProgressBar::new_spinner());
|
||||||
s.set_style(
|
s.set_style(
|
||||||
ProgressStyle::default_spinner()
|
ProgressStyle::default_spinner()
|
||||||
.template("{spinner:.blue} {msg}")
|
.template("{spinner:.cyan} {msg}")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
s.set_message(format!("Fetching {type_label} from {path}..."));
|
s.set_message(format!("Fetching {type_label} from {path}..."));
|
||||||
s.enable_steady_tick(std::time::Duration::from_millis(100));
|
s.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
s
|
s
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,12 +420,13 @@ async fn run_ingest_inner(
|
|||||||
b.set_style(
|
b.set_style(
|
||||||
ProgressStyle::default_bar()
|
ProgressStyle::default_bar()
|
||||||
.template(
|
.template(
|
||||||
" {spinner:.blue} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}",
|
" {spinner:.dim} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.progress_chars("=> "),
|
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||||
);
|
);
|
||||||
b.set_prefix(path.clone());
|
b.set_prefix(path.clone());
|
||||||
|
b.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
b
|
b
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,7 +463,7 @@ async fn run_ingest_inner(
|
|||||||
spinner_clone.finish_and_clear();
|
spinner_clone.finish_and_clear();
|
||||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||||
disc_bar_clone.set_length(total as u64);
|
disc_bar_clone.set_length(total as u64);
|
||||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Syncing discussions... (0/{agg_total})"
|
"Syncing discussions... (0/{agg_total})"
|
||||||
));
|
));
|
||||||
@@ -462,7 +483,7 @@ async fn run_ingest_inner(
|
|||||||
spinner_clone.finish_and_clear();
|
spinner_clone.finish_and_clear();
|
||||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||||
disc_bar_clone.set_length(total as u64);
|
disc_bar_clone.set_length(total as u64);
|
||||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Syncing discussions... (0/{agg_total})"
|
"Syncing discussions... (0/{agg_total})"
|
||||||
));
|
));
|
||||||
@@ -483,11 +504,11 @@ async fn run_ingest_inner(
|
|||||||
disc_bar_clone.set_length(total as u64);
|
disc_bar_clone.set_length(total as u64);
|
||||||
disc_bar_clone.set_style(
|
disc_bar_clone.set_style(
|
||||||
ProgressStyle::default_bar()
|
ProgressStyle::default_bar()
|
||||||
.template(" {spinner:.blue} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dim}] {pos}/{len}")
|
.template(" {spinner:.dim} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.progress_chars("=> "),
|
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||||
);
|
);
|
||||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
agg_events_total_clone.fetch_add(total, Ordering::Relaxed);
|
agg_events_total_clone.fetch_add(total, Ordering::Relaxed);
|
||||||
stage_bar_clone.set_message(
|
stage_bar_clone.set_message(
|
||||||
"Fetching resource events...".to_string()
|
"Fetching resource events...".to_string()
|
||||||
@@ -507,7 +528,7 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::ClosesIssuesFetchStarted { total } => {
|
ProgressEvent::ClosesIssuesFetchStarted { total } => {
|
||||||
disc_bar_clone.reset();
|
disc_bar_clone.reset();
|
||||||
disc_bar_clone.set_length(total as u64);
|
disc_bar_clone.set_length(total as u64);
|
||||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
stage_bar_clone.set_message(
|
stage_bar_clone.set_message(
|
||||||
"Fetching closes-issues references...".to_string()
|
"Fetching closes-issues references...".to_string()
|
||||||
);
|
);
|
||||||
@@ -521,7 +542,7 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::MrDiffsFetchStarted { total } => {
|
ProgressEvent::MrDiffsFetchStarted { total } => {
|
||||||
disc_bar_clone.reset();
|
disc_bar_clone.reset();
|
||||||
disc_bar_clone.set_length(total as u64);
|
disc_bar_clone.set_length(total as u64);
|
||||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
stage_bar_clone.set_message(
|
stage_bar_clone.set_message(
|
||||||
"Fetching MR file changes...".to_string()
|
"Fetching MR file changes...".to_string()
|
||||||
);
|
);
|
||||||
@@ -532,35 +553,37 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
||||||
disc_bar_clone.finish_and_clear();
|
disc_bar_clone.finish_and_clear();
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentStarted => {
|
ProgressEvent::StatusEnrichmentStarted { total } => {
|
||||||
spinner_clone.set_message(format!(
|
spinner_clone.finish_and_clear();
|
||||||
"{path_for_cb}: Enriching work item statuses..."
|
disc_bar_clone.reset();
|
||||||
));
|
disc_bar_clone.set_length(total as u64);
|
||||||
|
disc_bar_clone.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template(" {spinner:.dim} {prefix:.cyan} Statuses [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||||
|
);
|
||||||
|
disc_bar_clone.set_prefix(path_for_cb.clone());
|
||||||
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
stage_bar_clone.set_message(
|
stage_bar_clone.set_message(
|
||||||
"Enriching work item statuses...".to_string()
|
"Enriching work item statuses...".to_string()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
|
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
|
||||||
spinner_clone.set_message(format!(
|
disc_bar_clone.set_position(items_so_far as u64);
|
||||||
"{path_for_cb}: Fetching statuses... ({items_so_far} work items)"
|
|
||||||
));
|
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Enriching work item statuses... ({items_so_far} fetched)"
|
"Enriching work item statuses... ({items_so_far} fetched)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentWriting { total } => {
|
ProgressEvent::StatusEnrichmentWriting { total } => {
|
||||||
spinner_clone.set_message(format!(
|
disc_bar_clone.set_message(format!("Writing {total} statuses..."));
|
||||||
"{path_for_cb}: Writing {total} statuses..."
|
|
||||||
));
|
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Writing {total} work item statuses..."
|
"Writing {total} work item statuses..."
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
|
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
|
||||||
|
disc_bar_clone.finish_and_clear();
|
||||||
if enriched > 0 || cleared > 0 {
|
if enriched > 0 || cleared > 0 {
|
||||||
spinner_clone.set_message(format!(
|
|
||||||
"{path_for_cb}: {enriched} statuses enriched, {cleared} cleared"
|
|
||||||
));
|
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Status enrichment: {enriched} enriched, {cleared} cleared"
|
"Status enrichment: {enriched} enriched, {cleared} cleared"
|
||||||
));
|
));
|
||||||
@@ -643,6 +666,7 @@ async fn run_ingest_inner(
|
|||||||
total
|
total
|
||||||
.status_enrichment_projects
|
.status_enrichment_projects
|
||||||
.push(ProjectStatusEnrichment {
|
.push(ProjectStatusEnrichment {
|
||||||
|
path: path.clone(),
|
||||||
mode: result.status_enrichment_mode.clone(),
|
mode: result.status_enrichment_mode.clone(),
|
||||||
reason: result.status_unsupported_reason.clone(),
|
reason: result.status_unsupported_reason.clone(),
|
||||||
seen: result.statuses_seen,
|
seen: result.statuses_seen,
|
||||||
@@ -653,6 +677,19 @@ async fn run_ingest_inner(
|
|||||||
first_partial_error: result.first_partial_error.clone(),
|
first_partial_error: result.first_partial_error.clone(),
|
||||||
error: result.status_enrichment_error.clone(),
|
error: result.status_enrichment_error.clone(),
|
||||||
});
|
});
|
||||||
|
total.project_summaries.push(ProjectSummary {
|
||||||
|
path: path.clone(),
|
||||||
|
items_upserted: result.issues_upserted,
|
||||||
|
discussions_synced: result.discussions_fetched,
|
||||||
|
events_fetched: result.resource_events_fetched,
|
||||||
|
events_failed: result.resource_events_failed,
|
||||||
|
statuses_enriched: result.statuses_enriched,
|
||||||
|
statuses_seen: result.statuses_seen,
|
||||||
|
status_errors: result.partial_error_count
|
||||||
|
+ usize::from(result.status_enrichment_error.is_some()),
|
||||||
|
mr_diffs_fetched: 0,
|
||||||
|
mr_diffs_failed: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(ProjectIngestOutcome::Mrs {
|
Ok(ProjectIngestOutcome::Mrs {
|
||||||
ref path,
|
ref path,
|
||||||
@@ -676,6 +713,18 @@ async fn run_ingest_inner(
|
|||||||
total.resource_events_failed += result.resource_events_failed;
|
total.resource_events_failed += result.resource_events_failed;
|
||||||
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
||||||
total.mr_diffs_failed += result.mr_diffs_failed;
|
total.mr_diffs_failed += result.mr_diffs_failed;
|
||||||
|
total.project_summaries.push(ProjectSummary {
|
||||||
|
path: path.clone(),
|
||||||
|
items_upserted: result.mrs_upserted,
|
||||||
|
discussions_synced: result.discussions_fetched,
|
||||||
|
events_fetched: result.resource_events_fetched,
|
||||||
|
events_failed: result.resource_events_failed,
|
||||||
|
statuses_enriched: 0,
|
||||||
|
statuses_seen: 0,
|
||||||
|
status_errors: 0,
|
||||||
|
mr_diffs_fetched: result.mr_diffs_fetched,
|
||||||
|
mr_diffs_failed: result.mr_diffs_failed,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,7 +795,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {}: {} issues fetched{}",
|
" {}: {} issues fetched{}",
|
||||||
style(path).cyan(),
|
Theme::info().render(path),
|
||||||
result.issues_upserted,
|
result.issues_upserted,
|
||||||
labels_str
|
labels_str
|
||||||
);
|
);
|
||||||
@@ -761,7 +810,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
|
|||||||
if result.issues_skipped_discussion_sync > 0 {
|
if result.issues_skipped_discussion_sync > 0 {
|
||||||
println!(
|
println!(
|
||||||
" {} unchanged issues (discussion sync skipped)",
|
" {} unchanged issues (discussion sync skipped)",
|
||||||
style(result.issues_skipped_discussion_sync).dim()
|
Theme::dim().render(&result.issues_skipped_discussion_sync.to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -784,7 +833,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {}: {} MRs fetched{}{}",
|
" {}: {} MRs fetched{}{}",
|
||||||
style(path).cyan(),
|
Theme::info().render(path),
|
||||||
result.mrs_upserted,
|
result.mrs_upserted,
|
||||||
labels_str,
|
labels_str,
|
||||||
assignees_str
|
assignees_str
|
||||||
@@ -808,7 +857,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
|||||||
if result.mrs_skipped_discussion_sync > 0 {
|
if result.mrs_skipped_discussion_sync > 0 {
|
||||||
println!(
|
println!(
|
||||||
" {} unchanged MRs (discussion sync skipped)",
|
" {} unchanged MRs (discussion sync skipped)",
|
||||||
style(result.mrs_skipped_discussion_sync).dim()
|
Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -942,21 +991,19 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
|||||||
if result.resource_type == "issues" {
|
if result.resource_type == "issues" {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!(
|
Theme::success().render(&format!(
|
||||||
"Total: {} issues, {} discussions, {} notes",
|
"Total: {} issues, {} discussions, {} notes",
|
||||||
result.issues_upserted, result.discussions_fetched, result.notes_upserted
|
result.issues_upserted, result.discussions_fetched, result.notes_upserted
|
||||||
))
|
))
|
||||||
.green()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if result.issues_skipped_discussion_sync > 0 {
|
if result.issues_skipped_discussion_sync > 0 {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!(
|
Theme::dim().render(&format!(
|
||||||
"Skipped discussion sync for {} unchanged issues.",
|
"Skipped discussion sync for {} unchanged issues.",
|
||||||
result.issues_skipped_discussion_sync
|
result.issues_skipped_discussion_sync
|
||||||
))
|
))
|
||||||
.dim()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -968,24 +1015,22 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!(
|
Theme::success().render(&format!(
|
||||||
"Total: {} MRs, {} discussions, {} notes{}",
|
"Total: {} MRs, {} discussions, {} notes{}",
|
||||||
result.mrs_upserted,
|
result.mrs_upserted,
|
||||||
result.discussions_fetched,
|
result.discussions_fetched,
|
||||||
result.notes_upserted,
|
result.notes_upserted,
|
||||||
diffnotes_str
|
diffnotes_str
|
||||||
))
|
))
|
||||||
.green()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if result.mrs_skipped_discussion_sync > 0 {
|
if result.mrs_skipped_discussion_sync > 0 {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!(
|
Theme::dim().render(&format!(
|
||||||
"Skipped discussion sync for {} unchanged MRs.",
|
"Skipped discussion sync for {} unchanged MRs.",
|
||||||
result.mrs_skipped_discussion_sync
|
result.mrs_skipped_discussion_sync
|
||||||
))
|
))
|
||||||
.dim()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1006,8 +1051,8 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
|||||||
pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
||||||
println!(
|
println!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
style("Dry Run Preview").cyan().bold(),
|
Theme::info().bold().render("Dry Run Preview"),
|
||||||
style("(no changes will be made)").yellow()
|
Theme::warning().render("(no changes will be made)")
|
||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
@@ -1017,27 +1062,31 @@ pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
|||||||
"merge requests"
|
"merge requests"
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(" Resource type: {}", style(type_label).white().bold());
|
println!(" Resource type: {}", Theme::bold().render(type_label));
|
||||||
println!(
|
println!(
|
||||||
" Sync mode: {}",
|
" Sync mode: {}",
|
||||||
if preview.sync_mode == "full" {
|
if preview.sync_mode == "full" {
|
||||||
style("full (all data will be re-fetched)").yellow()
|
Theme::warning().render("full (all data will be re-fetched)")
|
||||||
} else {
|
} else {
|
||||||
style("incremental (only changes since last sync)").green()
|
Theme::success().render("incremental (only changes since last sync)")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
println!(" Projects: {}", preview.projects.len());
|
println!(" Projects: {}", preview.projects.len());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
println!("{}", style("Projects to sync:").cyan().bold());
|
println!("{}", Theme::info().bold().render("Projects to sync:"));
|
||||||
for project in &preview.projects {
|
for project in &preview.projects {
|
||||||
let sync_status = if !project.has_cursor {
|
let sync_status = if !project.has_cursor {
|
||||||
style("initial sync").yellow()
|
Theme::warning().render("initial sync")
|
||||||
} else {
|
} else {
|
||||||
style("incremental").green()
|
Theme::success().render("incremental")
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(" {} ({})", style(&project.path).white(), sync_status);
|
println!(
|
||||||
|
" {} ({})",
|
||||||
|
Theme::bold().render(&project.path),
|
||||||
|
sync_status
|
||||||
|
);
|
||||||
println!(" Existing {}: {}", type_label, project.existing_count);
|
println!(" Existing {}: {}", type_label, project.existing_count);
|
||||||
|
|
||||||
if let Some(ref last_synced) = project.last_synced {
|
if let Some(ref last_synced) = project.last_synced {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub struct InitInputs {
|
|||||||
pub gitlab_url: String,
|
pub gitlab_url: String,
|
||||||
pub token_env_var: String,
|
pub token_env_var: String,
|
||||||
pub project_paths: Vec<String>,
|
pub project_paths: Vec<String>,
|
||||||
|
pub default_project: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct InitOptions {
|
pub struct InitOptions {
|
||||||
@@ -23,6 +24,7 @@ pub struct InitResult {
|
|||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
pub user: UserInfo,
|
pub user: UserInfo,
|
||||||
pub projects: Vec<ProjectInfo>,
|
pub projects: Vec<ProjectInfo>,
|
||||||
|
pub default_project: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UserInfo {
|
pub struct UserInfo {
|
||||||
@@ -104,6 +106,20 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate default_project matches one of the configured project paths
|
||||||
|
if let Some(ref dp) = inputs.default_project {
|
||||||
|
let matched = inputs.project_paths.iter().any(|p| {
|
||||||
|
p.eq_ignore_ascii_case(dp)
|
||||||
|
|| p.to_ascii_lowercase()
|
||||||
|
.ends_with(&format!("/{}", dp.to_ascii_lowercase()))
|
||||||
|
});
|
||||||
|
if !matched {
|
||||||
|
return Err(LoreError::Other(format!(
|
||||||
|
"defaultProject '{dp}' does not match any configured project path"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(parent) = config_path.parent() {
|
if let Some(parent) = config_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
@@ -118,6 +134,7 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| ProjectConfig { path: p.clone() })
|
.map(|p| ProjectConfig { path: p.clone() })
|
||||||
.collect(),
|
.collect(),
|
||||||
|
default_project: inputs.default_project.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let config_json = serde_json::to_string_pretty(&config)?;
|
let config_json = serde_json::to_string_pretty(&config)?;
|
||||||
@@ -152,5 +169,6 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
|
|||||||
data_dir: data_dir.display().to_string(),
|
data_dir: data_dir.display().to_string(),
|
||||||
user,
|
user,
|
||||||
projects: validated_projects.into_iter().map(|(p, _)| p).collect(),
|
projects: validated_projects.into_iter().map(|(p, _)| p).collect(),
|
||||||
|
default_project: inputs.default_project,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
|
use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -6,41 +6,10 @@ use crate::Config;
|
|||||||
use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields};
|
use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields};
|
||||||
use crate::core::db::create_connection;
|
use crate::core::db::create_connection;
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
|
use crate::core::path_resolver::escape_like as note_escape_like;
|
||||||
use crate::core::paths::get_db_path;
|
use crate::core::paths::get_db_path;
|
||||||
use crate::core::project::resolve_project;
|
use crate::core::project::resolve_project;
|
||||||
use crate::core::time::{ms_to_iso, now_ms, parse_since};
|
use crate::core::time::{ms_to_iso, parse_since};
|
||||||
|
|
||||||
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
|
|
||||||
let cell = Cell::new(content);
|
|
||||||
if console::colors_enabled() {
|
|
||||||
cell.fg(color)
|
|
||||||
} else {
|
|
||||||
cell
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell {
|
|
||||||
if !console::colors_enabled() {
|
|
||||||
return Cell::new(content);
|
|
||||||
}
|
|
||||||
let Some(hex) = hex else {
|
|
||||||
return Cell::new(content);
|
|
||||||
};
|
|
||||||
let hex = hex.trim_start_matches('#');
|
|
||||||
if hex.len() != 6 {
|
|
||||||
return Cell::new(content);
|
|
||||||
}
|
|
||||||
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
|
||||||
return Cell::new(content);
|
|
||||||
};
|
|
||||||
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
|
||||||
return Cell::new(content);
|
|
||||||
};
|
|
||||||
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
|
||||||
return Cell::new(content);
|
|
||||||
};
|
|
||||||
Cell::new(content).fg(Color::Rgb { r, g, b })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct IssueListRow {
|
pub struct IssueListRow {
|
||||||
@@ -668,60 +637,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
|||||||
Ok(MrListResult { mrs, total_count })
|
Ok(MrListResult { mrs, total_count })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_relative_time(ms_epoch: i64) -> String {
|
|
||||||
let now = now_ms();
|
|
||||||
let diff = now - ms_epoch;
|
|
||||||
|
|
||||||
if diff < 0 {
|
|
||||||
return "in the future".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
match diff {
|
|
||||||
d if d < 60_000 => "just now".to_string(),
|
|
||||||
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
|
||||||
d if d < 86_400_000 => {
|
|
||||||
let n = d / 3_600_000;
|
|
||||||
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
|
|
||||||
}
|
|
||||||
d if d < 604_800_000 => {
|
|
||||||
let n = d / 86_400_000;
|
|
||||||
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
|
|
||||||
}
|
|
||||||
d if d < 2_592_000_000 => {
|
|
||||||
let n = d / 604_800_000;
|
|
||||||
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let n = diff / 2_592_000_000;
|
|
||||||
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
|
||||||
if s.chars().count() <= max_width {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
|
|
||||||
format!("{truncated}...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_labels(labels: &[String], max_shown: usize) -> String {
|
|
||||||
if labels.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
|
|
||||||
let overflow = labels.len().saturating_sub(max_shown);
|
|
||||||
|
|
||||||
if overflow > 0 {
|
|
||||||
format!("[{} +{}]", shown.join(", "), overflow)
|
|
||||||
} else {
|
|
||||||
format!("[{}]", shown.join(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_assignees(assignees: &[String]) -> String {
|
fn format_assignees(assignees: &[String]) -> String {
|
||||||
if assignees.is_empty() {
|
if assignees.is_empty() {
|
||||||
return "-".to_string();
|
return "-".to_string();
|
||||||
@@ -731,7 +646,7 @@ fn format_assignees(assignees: &[String]) -> String {
|
|||||||
let shown: Vec<String> = assignees
|
let shown: Vec<String> = assignees
|
||||||
.iter()
|
.iter()
|
||||||
.take(max_shown)
|
.take(max_shown)
|
||||||
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10)))
|
.map(|s| format!("@{}", render::truncate(s, 10)))
|
||||||
.collect();
|
.collect();
|
||||||
let overflow = assignees.len().saturating_sub(max_shown);
|
let overflow = assignees.len().saturating_sub(max_shown);
|
||||||
|
|
||||||
@@ -742,21 +657,23 @@ fn format_assignees(assignees: &[String]) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_discussions(total: i64, unresolved: i64) -> String {
|
fn format_discussions(total: i64, unresolved: i64) -> StyledCell {
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return String::new();
|
return StyledCell::plain(String::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
if unresolved > 0 {
|
if unresolved > 0 {
|
||||||
format!("{total}/{unresolved}!")
|
let text = format!("{total}/");
|
||||||
|
let warn = Theme::warning().render(&format!("{unresolved}!"));
|
||||||
|
StyledCell::plain(format!("{text}{warn}"))
|
||||||
} else {
|
} else {
|
||||||
format!("{total}")
|
StyledCell::plain(format!("{total}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
|
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
|
||||||
let full = format!("{} <- {}", target, source);
|
let full = format!("{} <- {}", target, source);
|
||||||
truncate_with_ellipsis(&full, max_width)
|
render::truncate(&full, max_width)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_list_issues(result: &ListResult) {
|
pub fn print_list_issues(result: &ListResult) {
|
||||||
@@ -766,71 +683,64 @@ pub fn print_list_issues(result: &ListResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Issues (showing {} of {})\n",
|
"{} {} of {}\n",
|
||||||
|
Theme::bold().render("Issues"),
|
||||||
result.issues.len(),
|
result.issues.len(),
|
||||||
result.total_count
|
result.total_count
|
||||||
);
|
);
|
||||||
|
|
||||||
let has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
|
let has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
|
||||||
|
|
||||||
let mut header = vec![
|
let mut headers = vec!["IID", "Title", "State"];
|
||||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("State").add_attribute(Attribute::Bold),
|
|
||||||
];
|
|
||||||
if has_any_status {
|
if has_any_status {
|
||||||
header.push(Cell::new("Status").add_attribute(Attribute::Bold));
|
headers.push("Status");
|
||||||
}
|
}
|
||||||
header.extend([
|
headers.extend(["Assignee", "Labels", "Disc", "Updated"]);
|
||||||
Cell::new("Assignee").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Labels").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let mut table = Table::new();
|
let mut table = LoreTable::new().headers(&headers).align(0, Align::Right);
|
||||||
table
|
|
||||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
|
||||||
.set_header(header);
|
|
||||||
|
|
||||||
for issue in &result.issues {
|
for issue in &result.issues {
|
||||||
let title = truncate_with_ellipsis(&issue.title, 45);
|
let title = render::truncate(&issue.title, 45);
|
||||||
let relative_time = format_relative_time(issue.updated_at);
|
let relative_time = render::format_relative_time_compact(issue.updated_at);
|
||||||
let labels = format_labels(&issue.labels, 2);
|
let labels = render::format_labels_bare(&issue.labels, 2);
|
||||||
let assignee = format_assignees(&issue.assignees);
|
let assignee = format_assignees(&issue.assignees);
|
||||||
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
||||||
|
|
||||||
let state_cell = if issue.state == "opened" {
|
let (icon, state_style) = if issue.state == "opened" {
|
||||||
colored_cell(&issue.state, Color::Green)
|
(Icons::issue_opened(), Theme::success())
|
||||||
} else {
|
} else {
|
||||||
colored_cell(&issue.state, Color::DarkGrey)
|
(Icons::issue_closed(), Theme::dim())
|
||||||
};
|
};
|
||||||
|
let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style);
|
||||||
|
|
||||||
let mut row = vec![
|
let mut row = vec![
|
||||||
colored_cell(format!("#{}", issue.iid), Color::Cyan),
|
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
|
||||||
Cell::new(title),
|
StyledCell::plain(title),
|
||||||
state_cell,
|
state_cell,
|
||||||
];
|
];
|
||||||
if has_any_status {
|
if has_any_status {
|
||||||
match &issue.status_name {
|
match &issue.status_name {
|
||||||
Some(status) => {
|
Some(status) => {
|
||||||
row.push(colored_cell_hex(status, issue.status_color.as_deref()));
|
row.push(StyledCell::plain(render::style_with_hex(
|
||||||
|
status,
|
||||||
|
issue.status_color.as_deref(),
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
row.push(Cell::new(""));
|
row.push(StyledCell::plain(""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.extend([
|
row.extend([
|
||||||
colored_cell(assignee, Color::Magenta),
|
StyledCell::styled(assignee, Theme::accent()),
|
||||||
colored_cell(labels, Color::Yellow),
|
StyledCell::styled(labels, Theme::warning()),
|
||||||
Cell::new(discussions),
|
discussions,
|
||||||
colored_cell(relative_time, Color::DarkGrey),
|
StyledCell::styled(relative_time, Theme::dim()),
|
||||||
]);
|
]);
|
||||||
table.add_row(row);
|
table.add_row(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{table}");
|
println!("{}", table.render());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||||
@@ -877,58 +787,53 @@ pub fn print_list_mrs(result: &MrListResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Merge Requests (showing {} of {})\n",
|
"{} {} of {}\n",
|
||||||
|
Theme::bold().render("Merge Requests"),
|
||||||
result.mrs.len(),
|
result.mrs.len(),
|
||||||
result.total_count
|
result.total_count
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut table = Table::new();
|
let mut table = LoreTable::new()
|
||||||
table
|
.headers(&[
|
||||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
"IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
|
||||||
.set_header(vec![
|
])
|
||||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
.align(0, Align::Right);
|
||||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("State").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Author").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Branches").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
|
||||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
|
||||||
]);
|
|
||||||
|
|
||||||
for mr in &result.mrs {
|
for mr in &result.mrs {
|
||||||
let title = if mr.draft {
|
let title = if mr.draft {
|
||||||
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
|
format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42))
|
||||||
} else {
|
} else {
|
||||||
truncate_with_ellipsis(&mr.title, 45)
|
render::truncate(&mr.title, 45)
|
||||||
};
|
};
|
||||||
|
|
||||||
let relative_time = format_relative_time(mr.updated_at);
|
let relative_time = render::format_relative_time_compact(mr.updated_at);
|
||||||
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
|
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
|
||||||
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
||||||
|
|
||||||
let state_cell = match mr.state.as_str() {
|
let (icon, style) = match mr.state.as_str() {
|
||||||
"opened" => colored_cell(&mr.state, Color::Green),
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||||
"merged" => colored_cell(&mr.state, Color::Magenta),
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||||
"closed" => colored_cell(&mr.state, Color::Red),
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||||
"locked" => colored_cell(&mr.state, Color::Yellow),
|
"locked" => (Icons::mr_opened(), Theme::warning()),
|
||||||
_ => colored_cell(&mr.state, Color::DarkGrey),
|
_ => (Icons::mr_opened(), Theme::dim()),
|
||||||
};
|
};
|
||||||
|
let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style);
|
||||||
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
colored_cell(format!("!{}", mr.iid), Color::Cyan),
|
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
|
||||||
Cell::new(title),
|
StyledCell::plain(title),
|
||||||
state_cell,
|
state_cell,
|
||||||
colored_cell(
|
StyledCell::styled(
|
||||||
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
|
format!("@{}", render::truncate(&mr.author_username, 12)),
|
||||||
Color::Magenta,
|
Theme::accent(),
|
||||||
),
|
),
|
||||||
colored_cell(branches, Color::Blue),
|
StyledCell::styled(branches, Theme::info()),
|
||||||
Cell::new(discussions),
|
discussions,
|
||||||
colored_cell(relative_time, Color::DarkGrey),
|
StyledCell::styled(relative_time, Theme::dim()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{table}");
|
println!("{}", table.render());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||||
@@ -966,77 +871,566 @@ pub fn open_mr_in_browser(result: &MrListResult) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
// ---------------------------------------------------------------------------
|
||||||
mod tests {
|
// Note output formatting
|
||||||
use super::*;
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
fn truncate_body(body: &str, max_len: usize) -> String {
|
||||||
fn truncate_leaves_short_strings_alone() {
|
if body.chars().count() <= max_len {
|
||||||
assert_eq!(truncate_with_ellipsis("short", 10), "short");
|
body.to_string()
|
||||||
}
|
} else {
|
||||||
|
let truncated: String = body.chars().take(max_len).collect();
|
||||||
#[test]
|
format!("{truncated}...")
|
||||||
fn truncate_adds_ellipsis_to_long_strings() {
|
|
||||||
assert_eq!(
|
|
||||||
truncate_with_ellipsis("this is a very long title", 15),
|
|
||||||
"this is a ve..."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncate_handles_exact_length() {
|
|
||||||
assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_time_formats_correctly() {
|
|
||||||
let now = now_ms();
|
|
||||||
|
|
||||||
assert_eq!(format_relative_time(now - 30_000), "just now");
|
|
||||||
assert_eq!(format_relative_time(now - 120_000), "2 min ago");
|
|
||||||
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago");
|
|
||||||
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_labels_empty() {
|
|
||||||
assert_eq!(format_labels(&[], 2), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_labels_single() {
|
|
||||||
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_labels_multiple() {
|
|
||||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
|
||||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_labels_overflow() {
|
|
||||||
let labels = vec![
|
|
||||||
"bug".to_string(),
|
|
||||||
"urgent".to_string(),
|
|
||||||
"wip".to_string(),
|
|
||||||
"blocked".to_string(),
|
|
||||||
];
|
|
||||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_discussions_empty() {
|
|
||||||
assert_eq!(format_discussions(0, 0), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_discussions_no_unresolved() {
|
|
||||||
assert_eq!(format_discussions(5, 0), "5");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_discussions_with_unresolved() {
|
|
||||||
assert_eq!(format_discussions(5, 2), "5/2!");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_note_type(note_type: Option<&str>) -> &str {
|
||||||
|
match note_type {
|
||||||
|
Some("DiffNote") => "Diff",
|
||||||
|
Some("DiscussionNote") => "Disc",
|
||||||
|
_ => "-",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_note_path(path: Option<&str>, line: Option<i64>) -> String {
|
||||||
|
match (path, line) {
|
||||||
|
(Some(p), Some(l)) => format!("{p}:{l}"),
|
||||||
|
(Some(p), None) => p.to_string(),
|
||||||
|
_ => "-".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option<i64>) -> String {
|
||||||
|
match (noteable_type, parent_iid) {
|
||||||
|
(Some("Issue"), Some(iid)) => format!("Issue #{iid}"),
|
||||||
|
(Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"),
|
||||||
|
_ => "-".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_list_notes(result: &NoteListResult) {
|
||||||
|
if result.notes.is_empty() {
|
||||||
|
println!("No notes found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {} of {}\n",
|
||||||
|
Theme::bold().render("Notes"),
|
||||||
|
result.notes.len(),
|
||||||
|
result.total_count
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut table = LoreTable::new()
|
||||||
|
.headers(&[
|
||||||
|
"ID",
|
||||||
|
"Author",
|
||||||
|
"Type",
|
||||||
|
"Body",
|
||||||
|
"Path:Line",
|
||||||
|
"Parent",
|
||||||
|
"Created",
|
||||||
|
])
|
||||||
|
.align(0, Align::Right);
|
||||||
|
|
||||||
|
for note in &result.notes {
|
||||||
|
let body = note
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(|b| truncate_body(b, 60))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
|
||||||
|
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
|
||||||
|
let relative_time = render::format_relative_time_compact(note.created_at);
|
||||||
|
let note_type = format_note_type(note.note_type.as_deref());
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
|
||||||
|
StyledCell::styled(
|
||||||
|
format!("@{}", render::truncate(¬e.author_username, 12)),
|
||||||
|
Theme::accent(),
|
||||||
|
),
|
||||||
|
StyledCell::plain(note_type),
|
||||||
|
StyledCell::plain(body),
|
||||||
|
StyledCell::plain(path),
|
||||||
|
StyledCell::plain(parent),
|
||||||
|
StyledCell::styled(relative_time, Theme::dim()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", table.render());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||||
|
let json_result = NoteListResultJson::from(result);
|
||||||
|
let meta = RobotMeta { elapsed_ms };
|
||||||
|
let output = serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"data": json_result,
|
||||||
|
"meta": meta,
|
||||||
|
});
|
||||||
|
let mut output = output;
|
||||||
|
if let Some(f) = fields {
|
||||||
|
let expanded = expand_fields_preset(f, "notes");
|
||||||
|
filter_fields(&mut output, "notes", &expanded);
|
||||||
|
}
|
||||||
|
match serde_json::to_string(&output) {
|
||||||
|
Ok(json) => println!("{json}"),
|
||||||
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_list_notes_jsonl(result: &NoteListResult) {
|
||||||
|
for note in &result.notes {
|
||||||
|
let json_row = NoteListRowJson::from(note);
|
||||||
|
match serde_json::to_string(&json_row) {
|
||||||
|
Ok(json) => println!("{json}"),
|
||||||
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape a field for RFC 4180 CSV: quote fields containing commas, quotes, or newlines.
|
||||||
|
fn csv_escape(field: &str) -> String {
|
||||||
|
if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
|
||||||
|
let escaped = field.replace('"', "\"\"");
|
||||||
|
format!("\"{escaped}\"")
|
||||||
|
} else {
|
||||||
|
field.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_list_notes_csv(result: &NoteListResult) {
|
||||||
|
println!(
|
||||||
|
"id,gitlab_id,author_username,body,note_type,is_system,created_at,updated_at,position_new_path,position_new_line,noteable_type,parent_iid,project_path"
|
||||||
|
);
|
||||||
|
for note in &result.notes {
|
||||||
|
let body = note.body.as_deref().unwrap_or("");
|
||||||
|
let note_type = note.note_type.as_deref().unwrap_or("");
|
||||||
|
let path = note.position_new_path.as_deref().unwrap_or("");
|
||||||
|
let line = note
|
||||||
|
.position_new_line
|
||||||
|
.map_or(String::new(), |l| l.to_string());
|
||||||
|
let noteable = note.noteable_type.as_deref().unwrap_or("");
|
||||||
|
let parent_iid = note.parent_iid.map_or(String::new(), |i| i.to_string());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{},{},{},{},{},{},{},{},{},{},{},{},{}",
|
||||||
|
note.id,
|
||||||
|
note.gitlab_id,
|
||||||
|
csv_escape(¬e.author_username),
|
||||||
|
csv_escape(body),
|
||||||
|
csv_escape(note_type),
|
||||||
|
note.is_system,
|
||||||
|
note.created_at,
|
||||||
|
note.updated_at,
|
||||||
|
csv_escape(path),
|
||||||
|
line,
|
||||||
|
csv_escape(noteable),
|
||||||
|
parent_iid,
|
||||||
|
csv_escape(¬e.project_path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Note query layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct NoteListRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub gitlab_id: i64,
|
||||||
|
pub author_username: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub note_type: Option<String>,
|
||||||
|
pub is_system: bool,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub position_new_path: Option<String>,
|
||||||
|
pub position_new_line: Option<i64>,
|
||||||
|
pub position_old_path: Option<String>,
|
||||||
|
pub position_old_line: Option<i64>,
|
||||||
|
pub resolvable: bool,
|
||||||
|
pub resolved: bool,
|
||||||
|
pub resolved_by: Option<String>,
|
||||||
|
pub noteable_type: Option<String>,
|
||||||
|
pub parent_iid: Option<i64>,
|
||||||
|
pub parent_title: Option<String>,
|
||||||
|
pub project_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NoteListRowJson {
|
||||||
|
pub id: i64,
|
||||||
|
pub gitlab_id: i64,
|
||||||
|
pub author_username: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note_type: Option<String>,
|
||||||
|
pub is_system: bool,
|
||||||
|
pub created_at_iso: String,
|
||||||
|
pub updated_at_iso: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position_new_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position_new_line: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position_old_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position_old_line: Option<i64>,
|
||||||
|
pub resolvable: bool,
|
||||||
|
pub resolved: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resolved_by: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub noteable_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_iid: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_title: Option<String>,
|
||||||
|
pub project_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&NoteListRow> for NoteListRowJson {
|
||||||
|
fn from(row: &NoteListRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: row.id,
|
||||||
|
gitlab_id: row.gitlab_id,
|
||||||
|
author_username: row.author_username.clone(),
|
||||||
|
body: row.body.clone(),
|
||||||
|
note_type: row.note_type.clone(),
|
||||||
|
is_system: row.is_system,
|
||||||
|
created_at_iso: ms_to_iso(row.created_at),
|
||||||
|
updated_at_iso: ms_to_iso(row.updated_at),
|
||||||
|
position_new_path: row.position_new_path.clone(),
|
||||||
|
position_new_line: row.position_new_line,
|
||||||
|
position_old_path: row.position_old_path.clone(),
|
||||||
|
position_old_line: row.position_old_line,
|
||||||
|
resolvable: row.resolvable,
|
||||||
|
resolved: row.resolved,
|
||||||
|
resolved_by: row.resolved_by.clone(),
|
||||||
|
noteable_type: row.noteable_type.clone(),
|
||||||
|
parent_iid: row.parent_iid,
|
||||||
|
parent_title: row.parent_title.clone(),
|
||||||
|
project_path: row.project_path.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NoteListResult {
|
||||||
|
pub notes: Vec<NoteListRow>,
|
||||||
|
pub total_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NoteListResultJson {
|
||||||
|
pub notes: Vec<NoteListRowJson>,
|
||||||
|
pub total_count: i64,
|
||||||
|
pub showing: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&NoteListResult> for NoteListResultJson {
|
||||||
|
fn from(result: &NoteListResult) -> Self {
|
||||||
|
Self {
|
||||||
|
notes: result.notes.iter().map(NoteListRowJson::from).collect(),
|
||||||
|
total_count: result.total_count,
|
||||||
|
showing: result.notes.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NoteListFilters {
|
||||||
|
pub limit: usize,
|
||||||
|
pub project: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub note_type: Option<String>,
|
||||||
|
pub include_system: bool,
|
||||||
|
pub for_issue_iid: Option<i64>,
|
||||||
|
pub for_mr_iid: Option<i64>,
|
||||||
|
pub note_id: Option<i64>,
|
||||||
|
pub gitlab_note_id: Option<i64>,
|
||||||
|
pub discussion_id: Option<String>,
|
||||||
|
pub since: Option<String>,
|
||||||
|
pub until: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub contains: Option<String>,
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
pub sort: String,
|
||||||
|
pub order: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_notes(
|
||||||
|
conn: &Connection,
|
||||||
|
filters: &NoteListFilters,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<NoteListResult> {
|
||||||
|
let mut where_clauses: Vec<String> = Vec::new();
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
// Project filter
|
||||||
|
if let Some(ref project) = filters.project {
|
||||||
|
let project_id = resolve_project(conn, project)?;
|
||||||
|
where_clauses.push("n.project_id = ?".to_string());
|
||||||
|
params.push(Box::new(project_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author filter (case-insensitive, strip leading @)
|
||||||
|
if let Some(ref author) = filters.author {
|
||||||
|
let username = author.strip_prefix('@').unwrap_or(author);
|
||||||
|
where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string());
|
||||||
|
params.push(Box::new(username.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note type filter
|
||||||
|
if let Some(ref note_type) = filters.note_type {
|
||||||
|
where_clauses.push("n.note_type = ?".to_string());
|
||||||
|
params.push(Box::new(note_type.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// System note filter (default: exclude system notes)
|
||||||
|
if !filters.include_system {
|
||||||
|
where_clauses.push("n.is_system = 0".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since filter
|
||||||
|
let since_ms = if let Some(ref since_str) = filters.since {
|
||||||
|
let ms = parse_since(since_str).ok_or_else(|| {
|
||||||
|
LoreError::Other(format!(
|
||||||
|
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||||
|
since_str
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
where_clauses.push("n.created_at >= ?".to_string());
|
||||||
|
params.push(Box::new(ms));
|
||||||
|
Some(ms)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Until filter (end of day for date-only input)
|
||||||
|
if let Some(ref until_str) = filters.until {
|
||||||
|
let until_ms = if until_str.len() == 10
|
||||||
|
&& until_str.chars().filter(|&c| c == '-').count() == 2
|
||||||
|
{
|
||||||
|
// Date-only: use end of day 23:59:59.999
|
||||||
|
let iso_full = format!("{until_str}T23:59:59.999Z");
|
||||||
|
crate::core::time::iso_to_ms(&iso_full).ok_or_else(|| {
|
||||||
|
LoreError::Other(format!(
|
||||||
|
"Invalid --until value '{}'. Use YYYY-MM-DD or relative format.",
|
||||||
|
until_str
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
parse_since(until_str).ok_or_else(|| {
|
||||||
|
LoreError::Other(format!(
|
||||||
|
"Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||||
|
until_str
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate since <= until
|
||||||
|
if let Some(s) = since_ms
|
||||||
|
&& s > until_ms
|
||||||
|
{
|
||||||
|
return Err(LoreError::Other(
|
||||||
|
"Invalid time window: --since is after --until.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
where_clauses.push("n.created_at <= ?".to_string());
|
||||||
|
params.push(Box::new(until_ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path filter (trailing / = prefix match, else exact)
|
||||||
|
if let Some(ref path) = filters.path {
|
||||||
|
if let Some(prefix) = path.strip_suffix('/') {
|
||||||
|
let escaped = note_escape_like(prefix);
|
||||||
|
where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string());
|
||||||
|
params.push(Box::new(format!("{escaped}%")));
|
||||||
|
} else {
|
||||||
|
where_clauses.push("n.position_new_path = ?".to_string());
|
||||||
|
params.push(Box::new(path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains filter (LIKE %term% on body, case-insensitive)
|
||||||
|
if let Some(ref contains) = filters.contains {
|
||||||
|
let escaped = note_escape_like(contains);
|
||||||
|
where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string());
|
||||||
|
params.push(Box::new(format!("%{escaped}%")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolution filter
|
||||||
|
if let Some(ref resolution) = filters.resolution {
|
||||||
|
match resolution.as_str() {
|
||||||
|
"unresolved" => {
|
||||||
|
where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string());
|
||||||
|
}
|
||||||
|
"resolved" => {
|
||||||
|
where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(LoreError::Other(format!(
|
||||||
|
"Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.",
|
||||||
|
other
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For-issue-iid filter (requires project context)
|
||||||
|
if let Some(iid) = filters.for_issue_iid {
|
||||||
|
let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| {
|
||||||
|
LoreError::Other(
|
||||||
|
"Cannot filter by issue IID without a project context. Use --project or set defaultProject in config."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let project_id = resolve_project(conn, project_str)?;
|
||||||
|
where_clauses.push(
|
||||||
|
"d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(),
|
||||||
|
);
|
||||||
|
params.push(Box::new(project_id));
|
||||||
|
params.push(Box::new(iid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For-mr-iid filter (requires project context)
|
||||||
|
if let Some(iid) = filters.for_mr_iid {
|
||||||
|
let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| {
|
||||||
|
LoreError::Other(
|
||||||
|
"Cannot filter by MR IID without a project context. Use --project or set defaultProject in config."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let project_id = resolve_project(conn, project_str)?;
|
||||||
|
where_clauses.push(
|
||||||
|
"d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
params.push(Box::new(project_id));
|
||||||
|
params.push(Box::new(iid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note ID filter
|
||||||
|
if let Some(id) = filters.note_id {
|
||||||
|
where_clauses.push("n.id = ?".to_string());
|
||||||
|
params.push(Box::new(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitLab note ID filter
|
||||||
|
if let Some(gitlab_id) = filters.gitlab_note_id {
|
||||||
|
where_clauses.push("n.gitlab_id = ?".to_string());
|
||||||
|
params.push(Box::new(gitlab_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discussion ID filter
|
||||||
|
if let Some(ref disc_id) = filters.discussion_id {
|
||||||
|
where_clauses.push("d.gitlab_discussion_id = ?".to_string());
|
||||||
|
params.push(Box::new(disc_id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_sql = if where_clauses.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE {}", where_clauses.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count query
|
||||||
|
let count_sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
JOIN projects p ON n.project_id = p.id
|
||||||
|
LEFT JOIN issues i ON d.issue_id = i.id
|
||||||
|
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
|
{where_sql}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
||||||
|
|
||||||
|
// Sort + order
|
||||||
|
let sort_column = match filters.sort.as_str() {
|
||||||
|
"updated" => "n.updated_at",
|
||||||
|
_ => "n.created_at",
|
||||||
|
};
|
||||||
|
let order = if filters.order == "asc" {
|
||||||
|
"ASC"
|
||||||
|
} else {
|
||||||
|
"DESC"
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_sql = format!(
|
||||||
|
"SELECT
|
||||||
|
n.id,
|
||||||
|
n.gitlab_id,
|
||||||
|
n.author_username,
|
||||||
|
n.body,
|
||||||
|
n.note_type,
|
||||||
|
n.is_system,
|
||||||
|
n.created_at,
|
||||||
|
n.updated_at,
|
||||||
|
n.position_new_path,
|
||||||
|
n.position_new_line,
|
||||||
|
n.position_old_path,
|
||||||
|
n.position_old_line,
|
||||||
|
n.resolvable,
|
||||||
|
n.resolved,
|
||||||
|
n.resolved_by,
|
||||||
|
d.noteable_type,
|
||||||
|
COALESCE(i.iid, m.iid) AS parent_iid,
|
||||||
|
COALESCE(i.title, m.title) AS parent_title,
|
||||||
|
p.path_with_namespace AS project_path
|
||||||
|
FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
JOIN projects p ON n.project_id = p.id
|
||||||
|
LEFT JOIN issues i ON d.issue_id = i.id
|
||||||
|
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY {sort_column} {order}, n.id {order}
|
||||||
|
LIMIT ?"
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(Box::new(filters.limit as i64));
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&query_sql)?;
|
||||||
|
let notes: Vec<NoteListRow> = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
let is_system_int: i64 = row.get(5)?;
|
||||||
|
let resolvable_int: i64 = row.get(12)?;
|
||||||
|
let resolved_int: i64 = row.get(13)?;
|
||||||
|
|
||||||
|
Ok(NoteListRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
gitlab_id: row.get(1)?,
|
||||||
|
author_username: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||||
|
body: row.get(3)?,
|
||||||
|
note_type: row.get(4)?,
|
||||||
|
is_system: is_system_int == 1,
|
||||||
|
created_at: row.get(6)?,
|
||||||
|
updated_at: row.get(7)?,
|
||||||
|
position_new_path: row.get(8)?,
|
||||||
|
position_new_line: row.get(9)?,
|
||||||
|
position_old_path: row.get(10)?,
|
||||||
|
position_old_line: row.get(11)?,
|
||||||
|
resolvable: resolvable_int == 1,
|
||||||
|
resolved: resolved_int == 1,
|
||||||
|
resolved_by: row.get(14)?,
|
||||||
|
noteable_type: row.get(15)?,
|
||||||
|
parent_iid: row.get(16)?,
|
||||||
|
parent_title: row.get(17)?,
|
||||||
|
project_path: row.get(18)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(NoteListResult { notes, total_count })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "list_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
1401
src/cli/commands/list_tests.rs
Normal file
1401
src/cli/commands/list_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
pub mod auth_test;
|
pub mod auth_test;
|
||||||
pub mod count;
|
pub mod count;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
|
pub mod drift;
|
||||||
pub mod embed;
|
pub mod embed;
|
||||||
|
pub mod file_history;
|
||||||
pub mod generate_docs;
|
pub mod generate_docs;
|
||||||
pub mod ingest;
|
pub mod ingest;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
@@ -12,6 +14,7 @@ pub mod stats;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod sync_status;
|
pub mod sync_status;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
pub mod trace;
|
||||||
pub mod who;
|
pub mod who;
|
||||||
|
|
||||||
pub use auth_test::run_auth_test;
|
pub use auth_test::run_auth_test;
|
||||||
@@ -20,7 +23,9 @@ pub use count::{
|
|||||||
run_count_events,
|
run_count_events,
|
||||||
};
|
};
|
||||||
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
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 embed::{print_embed, print_embed_json, run_embed};
|
||||||
|
pub use file_history::{print_file_history, print_file_history_json, run_file_history};
|
||||||
pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs};
|
pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs};
|
||||||
pub use ingest::{
|
pub use ingest::{
|
||||||
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
|
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
|
||||||
@@ -28,8 +33,10 @@ pub use ingest::{
|
|||||||
};
|
};
|
||||||
pub use init::{InitInputs, InitOptions, InitResult, run_init};
|
pub use init::{InitInputs, InitOptions, InitResult, run_init};
|
||||||
pub use list::{
|
pub use list::{
|
||||||
ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues,
|
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
|
||||||
print_list_issues_json, print_list_mrs, print_list_mrs_json, run_list_issues, run_list_mrs,
|
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
||||||
|
print_list_notes, print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl,
|
||||||
|
query_notes, run_list_issues, run_list_mrs,
|
||||||
};
|
};
|
||||||
pub use search::{
|
pub use search::{
|
||||||
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
|
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
|
||||||
@@ -42,4 +49,5 @@ pub use stats::{print_stats, print_stats_json, run_stats};
|
|||||||
pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync};
|
pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync};
|
||||||
pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status};
|
pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status};
|
||||||
pub use timeline::{TimelineParams, print_timeline, print_timeline_json_with_meta, run_timeline};
|
pub use timeline::{TimelineParams, print_timeline, print_timeline_json_with_meta, run_timeline};
|
||||||
|
pub use trace::{parse_trace_path, print_trace, print_trace_json};
|
||||||
pub use who::{WhoRun, print_who_human, print_who_json, run_who};
|
pub use who::{WhoRun, print_who_human, print_who_json, run_who};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use console::style;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::cli::render::Theme;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
@@ -8,9 +10,10 @@ use crate::core::paths::get_db_path;
|
|||||||
use crate::core::project::resolve_project;
|
use crate::core::project::resolve_project;
|
||||||
use crate::core::time::{ms_to_iso, parse_since};
|
use crate::core::time::{ms_to_iso, parse_since};
|
||||||
use crate::documents::SourceType;
|
use crate::documents::SourceType;
|
||||||
|
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||||
use crate::search::{
|
use crate::search::{
|
||||||
FtsQueryMode, PathFilter, SearchFilters, apply_filters, get_result_snippet, rank_rrf,
|
FtsQueryMode, HybridResult, PathFilter, SearchFilters, SearchMode, get_result_snippet,
|
||||||
search_fts,
|
search_fts, search_hybrid,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -58,7 +61,7 @@ pub struct SearchCliFilters {
|
|||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_search(
|
pub async fn run_search(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
query: &str,
|
query: &str,
|
||||||
cli_filters: SearchCliFilters,
|
cli_filters: SearchCliFilters,
|
||||||
@@ -71,15 +74,18 @@ pub fn run_search(
|
|||||||
|
|
||||||
let mut warnings: Vec<String> = Vec::new();
|
let mut warnings: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// Determine actual mode: vector search requires embeddings, which need async + Ollama.
|
let actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);
|
||||||
// Until hybrid/semantic are wired up, we run lexical and warn if the user asked for more.
|
|
||||||
let actual_mode = "lexical";
|
let client = if actual_mode != SearchMode::Lexical {
|
||||||
if requested_mode != "lexical" {
|
let ollama_cfg = &config.embedding;
|
||||||
warnings.push(format!(
|
Some(OllamaClient::new(OllamaConfig {
|
||||||
"Requested mode '{}' is not yet available; falling back to lexical search.",
|
base_url: ollama_cfg.base_url.clone(),
|
||||||
requested_mode
|
model: ollama_cfg.model.clone(),
|
||||||
));
|
..OllamaConfig::default()
|
||||||
}
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let doc_count: i64 = conn
|
let doc_count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
||||||
@@ -89,7 +95,7 @@ pub fn run_search(
|
|||||||
warnings.push("No documents indexed. Run 'lore generate-docs' first.".to_string());
|
warnings.push("No documents indexed. Run 'lore generate-docs' first.".to_string());
|
||||||
return Ok(SearchResponse {
|
return Ok(SearchResponse {
|
||||||
query: query.to_string(),
|
query: query.to_string(),
|
||||||
mode: actual_mode.to_string(),
|
mode: actual_mode.as_str().to_string(),
|
||||||
total_results: 0,
|
total_results: 0,
|
||||||
results: vec![],
|
results: vec![],
|
||||||
warnings,
|
warnings,
|
||||||
@@ -151,52 +157,54 @@ pub fn run_search(
|
|||||||
limit: cli_filters.limit,
|
limit: cli_filters.limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
let requested = filters.clamp_limit();
|
// Run FTS separately for snippet extraction (search_hybrid doesn't return snippets).
|
||||||
let top_k = if filters.has_any_filter() {
|
let snippet_top_k = filters
|
||||||
(requested * 50).clamp(200, 1500)
|
.clamp_limit()
|
||||||
} else {
|
.checked_mul(10)
|
||||||
(requested * 10).clamp(50, 1500)
|
.unwrap_or(500)
|
||||||
};
|
.clamp(50, 1500);
|
||||||
|
let fts_results = search_fts(&conn, query, snippet_top_k, fts_mode)?;
|
||||||
let fts_results = search_fts(&conn, query, top_k, fts_mode)?;
|
let snippet_map: HashMap<i64, String> = fts_results
|
||||||
let fts_tuples: Vec<(i64, f64)> = fts_results
|
|
||||||
.iter()
|
|
||||||
.map(|r| (r.document_id, r.bm25_score))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let snippet_map: std::collections::HashMap<i64, String> = fts_results
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| (r.document_id, r.snippet.clone()))
|
.map(|r| (r.document_id, r.snippet.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let ranked = rank_rrf(&[], &fts_tuples);
|
// search_hybrid handles recall sizing, RRF ranking, and filter application internally.
|
||||||
let ranked_ids: Vec<i64> = ranked.iter().map(|r| r.document_id).collect();
|
let (hybrid_results, mut hybrid_warnings) = search_hybrid(
|
||||||
|
&conn,
|
||||||
|
client.as_ref(),
|
||||||
|
query,
|
||||||
|
actual_mode,
|
||||||
|
&filters,
|
||||||
|
fts_mode,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
warnings.append(&mut hybrid_warnings);
|
||||||
|
|
||||||
let filtered_ids = apply_filters(&conn, &ranked_ids, &filters)?;
|
if hybrid_results.is_empty() {
|
||||||
|
|
||||||
if filtered_ids.is_empty() {
|
|
||||||
return Ok(SearchResponse {
|
return Ok(SearchResponse {
|
||||||
query: query.to_string(),
|
query: query.to_string(),
|
||||||
mode: actual_mode.to_string(),
|
mode: actual_mode.as_str().to_string(),
|
||||||
total_results: 0,
|
total_results: 0,
|
||||||
results: vec![],
|
results: vec![],
|
||||||
warnings,
|
warnings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let hydrated = hydrate_results(&conn, &filtered_ids)?;
|
let ranked_ids: Vec<i64> = hybrid_results.iter().map(|r| r.document_id).collect();
|
||||||
|
let hydrated = hydrate_results(&conn, &ranked_ids)?;
|
||||||
|
|
||||||
let rrf_map: std::collections::HashMap<i64, &crate::search::RrfResult> =
|
let hybrid_map: HashMap<i64, &HybridResult> =
|
||||||
ranked.iter().map(|r| (r.document_id, r)).collect();
|
hybrid_results.iter().map(|r| (r.document_id, r)).collect();
|
||||||
|
|
||||||
let mut results: Vec<SearchResultDisplay> = Vec::with_capacity(hydrated.len());
|
let mut results: Vec<SearchResultDisplay> = Vec::with_capacity(hydrated.len());
|
||||||
for row in &hydrated {
|
for row in &hydrated {
|
||||||
let rrf = rrf_map.get(&row.document_id);
|
let hr = hybrid_map.get(&row.document_id);
|
||||||
let fts_snippet = snippet_map.get(&row.document_id).map(|s| s.as_str());
|
let fts_snippet = snippet_map.get(&row.document_id).map(|s| s.as_str());
|
||||||
let snippet = get_result_snippet(fts_snippet, &row.content_text);
|
let snippet = get_result_snippet(fts_snippet, &row.content_text);
|
||||||
|
|
||||||
let explain_data = if explain {
|
let explain_data = if explain {
|
||||||
rrf.map(|r| ExplainData {
|
hr.map(|r| ExplainData {
|
||||||
vector_rank: r.vector_rank,
|
vector_rank: r.vector_rank,
|
||||||
fts_rank: r.fts_rank,
|
fts_rank: r.fts_rank,
|
||||||
rrf_score: r.rrf_score,
|
rrf_score: r.rrf_score,
|
||||||
@@ -217,14 +225,14 @@ pub fn run_search(
|
|||||||
labels: row.labels.clone(),
|
labels: row.labels.clone(),
|
||||||
paths: row.paths.clone(),
|
paths: row.paths.clone(),
|
||||||
snippet,
|
snippet,
|
||||||
score: rrf.map(|r| r.normalized_score).unwrap_or(0.0),
|
score: hr.map(|r| r.score).unwrap_or(0.0),
|
||||||
explain: explain_data,
|
explain: explain_data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SearchResponse {
|
Ok(SearchResponse {
|
||||||
query: query.to_string(),
|
query: query.to_string(),
|
||||||
mode: actual_mode.to_string(),
|
mode: actual_mode.as_str().to_string(),
|
||||||
total_results: results.len(),
|
total_results: results.len(),
|
||||||
results,
|
results,
|
||||||
warnings,
|
warnings,
|
||||||
@@ -301,67 +309,97 @@ fn parse_json_array(json: &str) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render FTS snippet with `<mark>` tags as terminal highlight style.
|
||||||
|
fn render_snippet(snippet: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut remaining = snippet;
|
||||||
|
while let Some(start) = remaining.find("<mark>") {
|
||||||
|
result.push_str(&Theme::muted().render(&remaining[..start]));
|
||||||
|
remaining = &remaining[start + 6..];
|
||||||
|
if let Some(end) = remaining.find("</mark>") {
|
||||||
|
let highlighted = &remaining[..end];
|
||||||
|
result.push_str(&Theme::highlight().render(highlighted));
|
||||||
|
remaining = &remaining[end + 7..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(&Theme::muted().render(remaining));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_search_results(response: &SearchResponse) {
|
pub fn print_search_results(response: &SearchResponse) {
|
||||||
if !response.warnings.is_empty() {
|
if !response.warnings.is_empty() {
|
||||||
for w in &response.warnings {
|
for w in &response.warnings {
|
||||||
eprintln!("{} {}", style("Warning:").yellow(), w);
|
eprintln!("{} {}", Theme::warning().render("Warning:"), w);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.results.is_empty() {
|
if response.results.is_empty() {
|
||||||
println!("No results found for '{}'", style(&response.query).bold());
|
println!(
|
||||||
|
"No results found for '{}'",
|
||||||
|
Theme::bold().render(&response.query)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{} results for '{}' ({})",
|
"\n {} results for '{}' {}",
|
||||||
response.total_results,
|
Theme::bold().render(&response.total_results.to_string()),
|
||||||
style(&response.query).bold(),
|
Theme::bold().render(&response.query),
|
||||||
response.mode
|
Theme::muted().render(&response.mode)
|
||||||
);
|
);
|
||||||
println!();
|
|
||||||
|
|
||||||
for (i, result) in response.results.iter().enumerate() {
|
for (i, result) in response.results.iter().enumerate() {
|
||||||
let type_prefix = match result.source_type.as_str() {
|
println!();
|
||||||
"issue" => "Issue",
|
|
||||||
"merge_request" => "MR",
|
let type_badge = match result.source_type.as_str() {
|
||||||
"discussion" => "Discussion",
|
"issue" => Theme::issue_ref().render("issue"),
|
||||||
_ => &result.source_type,
|
"merge_request" => Theme::mr_ref().render(" mr "),
|
||||||
|
"discussion" => Theme::info().render(" disc"),
|
||||||
|
"note" => Theme::muted().render(" note"),
|
||||||
|
_ => Theme::muted().render(&format!("{:>5}", &result.source_type)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Title line: rank, type badge, title
|
||||||
println!(
|
println!(
|
||||||
"[{}] {} - {} (score: {:.2})",
|
" {:>3}. {} {}",
|
||||||
i + 1,
|
Theme::muted().render(&(i + 1).to_string()),
|
||||||
style(type_prefix).cyan(),
|
type_badge,
|
||||||
result.title,
|
Theme::bold().render(&result.title)
|
||||||
result.score
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(ref url) = result.url {
|
// Metadata: project, author, labels — compact middle-dot line
|
||||||
println!(" {}", style(url).dim());
|
let sep = Theme::muted().render(" \u{b7} ");
|
||||||
|
let mut meta_parts: Vec<String> = Vec::new();
|
||||||
|
meta_parts.push(Theme::muted().render(&result.project_path));
|
||||||
|
if let Some(ref author) = result.author {
|
||||||
|
meta_parts.push(Theme::username().render(&format!("@{author}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
|
||||||
" {} | {}",
|
|
||||||
style(&result.project_path).dim(),
|
|
||||||
result
|
|
||||||
.author
|
|
||||||
.as_deref()
|
|
||||||
.map(|a| format!("@{}", a))
|
|
||||||
.unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
if !result.labels.is_empty() {
|
if !result.labels.is_empty() {
|
||||||
println!(" Labels: {}", result.labels.join(", "));
|
let label_str = if result.labels.len() <= 3 {
|
||||||
|
result.labels.join(", ")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} +{}",
|
||||||
|
result.labels[..2].join(", "),
|
||||||
|
result.labels.len() - 2
|
||||||
|
)
|
||||||
|
};
|
||||||
|
meta_parts.push(Theme::muted().render(&label_str));
|
||||||
}
|
}
|
||||||
|
println!(" {}", meta_parts.join(&sep));
|
||||||
|
|
||||||
let clean_snippet = result.snippet.replace("<mark>", "").replace("</mark>", "");
|
// Snippet with highlight styling
|
||||||
println!(" {}", style(clean_snippet).dim());
|
let rendered = render_snippet(&result.snippet);
|
||||||
|
println!(" {rendered}");
|
||||||
|
|
||||||
if let Some(ref explain) = result.explain {
|
if let Some(ref explain) = result.explain {
|
||||||
println!(
|
println!(
|
||||||
" {} fts_rank={} rrf_score={:.6}",
|
" {} vec={} fts={} rrf={:.4}",
|
||||||
style("[explain]").magenta(),
|
Theme::accent().render("explain"),
|
||||||
|
explain
|
||||||
|
.vector_rank
|
||||||
|
.map(|r| r.to_string())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
explain
|
explain
|
||||||
.fts_rank
|
.fts_rank
|
||||||
.map(|r| r.to_string())
|
.map(|r| r.to_string())
|
||||||
@@ -369,9 +407,9 @@ pub fn print_search_results(response: &SearchResponse) {
|
|||||||
explain.rrf_score
|
explain.rrf_score
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -75,12 +75,17 @@ pub struct IssueDetail {
|
|||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
|
pub closed_at: Option<String>,
|
||||||
|
pub confidential: bool,
|
||||||
pub web_url: Option<String>,
|
pub web_url: Option<String>,
|
||||||
pub project_path: String,
|
pub project_path: String,
|
||||||
|
pub references_full: String,
|
||||||
pub labels: Vec<String>,
|
pub labels: Vec<String>,
|
||||||
pub assignees: Vec<String>,
|
pub assignees: Vec<String>,
|
||||||
pub due_date: Option<String>,
|
pub due_date: Option<String>,
|
||||||
pub milestone: Option<String>,
|
pub milestone: Option<String>,
|
||||||
|
pub user_notes_count: i64,
|
||||||
|
pub merge_requests_count: usize,
|
||||||
pub closing_merge_requests: Vec<ClosingMrRef>,
|
pub closing_merge_requests: Vec<ClosingMrRef>,
|
||||||
pub discussions: Vec<DiscussionDetail>,
|
pub discussions: Vec<DiscussionDetail>,
|
||||||
pub status_name: Option<String>,
|
pub status_name: Option<String>,
|
||||||
@@ -122,6 +127,9 @@ pub fn run_show_issue(
|
|||||||
|
|
||||||
let discussions = get_issue_discussions(&conn, issue.id)?;
|
let discussions = get_issue_discussions(&conn, issue.id)?;
|
||||||
|
|
||||||
|
let references_full = format!("{}#{}", issue.project_path, issue.iid);
|
||||||
|
let merge_requests_count = closing_mrs.len();
|
||||||
|
|
||||||
Ok(IssueDetail {
|
Ok(IssueDetail {
|
||||||
id: issue.id,
|
id: issue.id,
|
||||||
iid: issue.iid,
|
iid: issue.iid,
|
||||||
@@ -131,12 +139,17 @@ pub fn run_show_issue(
|
|||||||
author_username: issue.author_username,
|
author_username: issue.author_username,
|
||||||
created_at: issue.created_at,
|
created_at: issue.created_at,
|
||||||
updated_at: issue.updated_at,
|
updated_at: issue.updated_at,
|
||||||
|
closed_at: issue.closed_at,
|
||||||
|
confidential: issue.confidential,
|
||||||
web_url: issue.web_url,
|
web_url: issue.web_url,
|
||||||
project_path: issue.project_path,
|
project_path: issue.project_path,
|
||||||
|
references_full,
|
||||||
labels,
|
labels,
|
||||||
assignees,
|
assignees,
|
||||||
due_date: issue.due_date,
|
due_date: issue.due_date,
|
||||||
milestone: issue.milestone_title,
|
milestone: issue.milestone_title,
|
||||||
|
user_notes_count: issue.user_notes_count,
|
||||||
|
merge_requests_count,
|
||||||
closing_merge_requests: closing_mrs,
|
closing_merge_requests: closing_mrs,
|
||||||
discussions,
|
discussions,
|
||||||
status_name: issue.status_name,
|
status_name: issue.status_name,
|
||||||
@@ -147,6 +160,7 @@ pub fn run_show_issue(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct IssueRow {
|
struct IssueRow {
|
||||||
id: i64,
|
id: i64,
|
||||||
iid: i64,
|
iid: i64,
|
||||||
@@ -156,10 +170,13 @@ struct IssueRow {
|
|||||||
author_username: String,
|
author_username: String,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
|
closed_at: Option<String>,
|
||||||
|
confidential: bool,
|
||||||
web_url: Option<String>,
|
web_url: Option<String>,
|
||||||
project_path: String,
|
project_path: String,
|
||||||
due_date: Option<String>,
|
due_date: Option<String>,
|
||||||
milestone_title: Option<String>,
|
milestone_title: Option<String>,
|
||||||
|
user_notes_count: i64,
|
||||||
status_name: Option<String>,
|
status_name: Option<String>,
|
||||||
status_category: Option<String>,
|
status_category: Option<String>,
|
||||||
status_color: Option<String>,
|
status_color: Option<String>,
|
||||||
@@ -173,8 +190,12 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
|||||||
let project_id = resolve_project(conn, project)?;
|
let project_id = resolve_project(conn, project)?;
|
||||||
(
|
(
|
||||||
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
||||||
i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
|
i.created_at, i.updated_at, i.closed_at, i.confidential,
|
||||||
|
i.web_url, p.path_with_namespace,
|
||||||
i.due_date, i.milestone_title,
|
i.due_date, i.milestone_title,
|
||||||
|
(SELECT COUNT(*) FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||||
i.status_name, i.status_category, i.status_color,
|
i.status_name, i.status_category, i.status_color,
|
||||||
i.status_icon_name, i.status_synced_at
|
i.status_icon_name, i.status_synced_at
|
||||||
FROM issues i
|
FROM issues i
|
||||||
@@ -185,8 +206,12 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
|||||||
}
|
}
|
||||||
None => (
|
None => (
|
||||||
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
||||||
i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
|
i.created_at, i.updated_at, i.closed_at, i.confidential,
|
||||||
|
i.web_url, p.path_with_namespace,
|
||||||
i.due_date, i.milestone_title,
|
i.due_date, i.milestone_title,
|
||||||
|
(SELECT COUNT(*) FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||||
i.status_name, i.status_category, i.status_color,
|
i.status_name, i.status_category, i.status_color,
|
||||||
i.status_icon_name, i.status_synced_at
|
i.status_icon_name, i.status_synced_at
|
||||||
FROM issues i
|
FROM issues i
|
||||||
@@ -201,6 +226,7 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
|||||||
let mut stmt = conn.prepare(sql)?;
|
let mut stmt = conn.prepare(sql)?;
|
||||||
let issues: Vec<IssueRow> = stmt
|
let issues: Vec<IssueRow> = stmt
|
||||||
.query_map(param_refs.as_slice(), |row| {
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
let confidential_val: i64 = row.get(9)?;
|
||||||
Ok(IssueRow {
|
Ok(IssueRow {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
iid: row.get(1)?,
|
iid: row.get(1)?,
|
||||||
@@ -210,15 +236,18 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
|||||||
author_username: row.get(5)?,
|
author_username: row.get(5)?,
|
||||||
created_at: row.get(6)?,
|
created_at: row.get(6)?,
|
||||||
updated_at: row.get(7)?,
|
updated_at: row.get(7)?,
|
||||||
web_url: row.get(8)?,
|
closed_at: row.get(8)?,
|
||||||
project_path: row.get(9)?,
|
confidential: confidential_val != 0,
|
||||||
due_date: row.get(10)?,
|
web_url: row.get(10)?,
|
||||||
milestone_title: row.get(11)?,
|
project_path: row.get(11)?,
|
||||||
status_name: row.get(12)?,
|
due_date: row.get(12)?,
|
||||||
status_category: row.get(13)?,
|
milestone_title: row.get(13)?,
|
||||||
status_color: row.get(14)?,
|
user_notes_count: row.get(14)?,
|
||||||
status_icon_name: row.get(15)?,
|
status_name: row.get(15)?,
|
||||||
status_synced_at: row.get(16)?,
|
status_category: row.get(16)?,
|
||||||
|
status_color: row.get(17)?,
|
||||||
|
status_icon_name: row.get(18)?,
|
||||||
|
status_synced_at: row.get(19)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -577,64 +606,55 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn format_date(ms: i64) -> String {
|
fn format_date(ms: i64) -> String {
|
||||||
let iso = ms_to_iso(ms);
|
render::format_date(ms)
|
||||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
||||||
let mut result = String::new();
|
render::wrap_indent(text, width, indent)
|
||||||
let mut current_line = String::new();
|
|
||||||
|
|
||||||
for word in text.split_whitespace() {
|
|
||||||
if current_line.is_empty() {
|
|
||||||
current_line = word.to_string();
|
|
||||||
} else if current_line.len() + 1 + word.len() <= width {
|
|
||||||
current_line.push(' ');
|
|
||||||
current_line.push_str(word);
|
|
||||||
} else {
|
|
||||||
if !result.is_empty() {
|
|
||||||
result.push('\n');
|
|
||||||
result.push_str(indent);
|
|
||||||
}
|
|
||||||
result.push_str(¤t_line);
|
|
||||||
current_line = word.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !current_line.is_empty() {
|
|
||||||
if !result.is_empty() {
|
|
||||||
result.push('\n');
|
|
||||||
result.push_str(indent);
|
|
||||||
}
|
|
||||||
result.push_str(¤t_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_show_issue(issue: &IssueDetail) {
|
pub fn print_show_issue(issue: &IssueDetail) {
|
||||||
let header = format!("Issue #{}: {}", issue.iid, issue.title);
|
// Title line
|
||||||
println!("{}", style(&header).bold());
|
println!(
|
||||||
println!("{}", "━".repeat(header.len().min(80)));
|
" Issue #{}: {}",
|
||||||
println!();
|
issue.iid,
|
||||||
|
Theme::bold().render(&issue.title),
|
||||||
|
);
|
||||||
|
|
||||||
println!("Project: {}", style(&issue.project_path).cyan());
|
// Details section
|
||||||
|
println!("{}", render::section_divider("Details"));
|
||||||
|
|
||||||
let state_styled = if issue.state == "opened" {
|
println!(
|
||||||
style(&issue.state).green()
|
" Ref {}",
|
||||||
|
Theme::muted().render(&issue.references_full)
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Project {}",
|
||||||
|
Theme::info().render(&issue.project_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
let (icon, state_style) = if issue.state == "opened" {
|
||||||
|
(Icons::issue_opened(), Theme::success())
|
||||||
} else {
|
} else {
|
||||||
style(&issue.state).dim()
|
(Icons::issue_closed(), Theme::dim())
|
||||||
};
|
};
|
||||||
println!("State: {}", state_styled);
|
println!(
|
||||||
|
" State {}",
|
||||||
|
state_style.render(&format!("{icon} {}", issue.state))
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(status) = &issue.status_name {
|
if let Some(status) = &issue.status_name {
|
||||||
println!(
|
println!(
|
||||||
"Status: {}",
|
" Status {}",
|
||||||
style_with_hex(status, issue.status_color.as_deref())
|
render::style_with_hex(status, issue.status_color.as_deref())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Author: @{}", issue.author_username);
|
if issue.confidential {
|
||||||
|
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" Author @{}", issue.author_username);
|
||||||
|
|
||||||
if !issue.assignees.is_empty() {
|
if !issue.assignees.is_empty() {
|
||||||
let label = if issue.assignees.len() > 1 {
|
let label = if issue.assignees.len() > 1 {
|
||||||
@@ -643,65 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
"Assignee"
|
"Assignee"
|
||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
"{}:{} {}",
|
" {}{} {}",
|
||||||
label,
|
label,
|
||||||
" ".repeat(10 - label.len()),
|
" ".repeat(12 - label.len()),
|
||||||
issue
|
issue
|
||||||
.assignees
|
.assignees
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| format!("@{}", a))
|
.map(|a| format!("@{a}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Created: {}", format_date(issue.created_at));
|
println!(
|
||||||
println!("Updated: {}", format_date(issue.updated_at));
|
" Created {} ({})",
|
||||||
|
format_date(issue.created_at),
|
||||||
|
render::format_relative_time_compact(issue.created_at),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Updated {} ({})",
|
||||||
|
format_date(issue.updated_at),
|
||||||
|
render::format_relative_time_compact(issue.updated_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(closed_at) = &issue.closed_at {
|
||||||
|
println!(" Closed {closed_at}");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(due) = &issue.due_date {
|
if let Some(due) = &issue.due_date {
|
||||||
println!("Due: {}", due);
|
println!(" Due {due}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ms) = &issue.milestone {
|
if let Some(ms) = &issue.milestone {
|
||||||
println!("Milestone: {}", ms);
|
println!(" Milestone {ms}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.labels.is_empty() {
|
if !issue.labels.is_empty() {
|
||||||
println!("Labels: {}", style("(none)").dim());
|
println!(
|
||||||
} else {
|
" Labels {}",
|
||||||
println!("Labels: {}", issue.labels.join(", "));
|
render::format_labels_bare(&issue.labels, issue.labels.len())
|
||||||
}
|
);
|
||||||
|
|
||||||
if !issue.closing_merge_requests.is_empty() {
|
|
||||||
println!();
|
|
||||||
println!("{}", style("Development:").bold());
|
|
||||||
for mr in &issue.closing_merge_requests {
|
|
||||||
let state_indicator = match mr.state.as_str() {
|
|
||||||
"merged" => style(&mr.state).green(),
|
|
||||||
"opened" => style(&mr.state).cyan(),
|
|
||||||
"closed" => style(&mr.state).red(),
|
|
||||||
_ => style(&mr.state).dim(),
|
|
||||||
};
|
|
||||||
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = &issue.web_url {
|
if let Some(url) = &issue.web_url {
|
||||||
println!("URL: {}", style(url).dim());
|
println!(" URL {}", Theme::muted().render(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Development section
|
||||||
|
if !issue.closing_merge_requests.is_empty() {
|
||||||
|
println!("{}", render::section_divider("Development"));
|
||||||
|
for mr in &issue.closing_merge_requests {
|
||||||
|
let (mr_icon, mr_style) = match mr.state.as_str() {
|
||||||
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||||
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||||
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||||
|
_ => (Icons::mr_opened(), Theme::dim()),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {} !{} {} {}",
|
||||||
|
mr_style.render(mr_icon),
|
||||||
|
mr.iid,
|
||||||
|
mr.title,
|
||||||
|
mr_style.render(&mr.state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
println!("{}", style("Description:").bold());
|
// Description section
|
||||||
|
println!("{}", render::section_divider("Description"));
|
||||||
if let Some(desc) = &issue.description {
|
if let Some(desc) = &issue.description {
|
||||||
let wrapped = wrap_text(desc, 76, " ");
|
let wrapped = wrap_text(desc, 72, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
} else {
|
} else {
|
||||||
println!(" {}", style("(no description)").dim());
|
println!(" {}", Theme::muted().render("(no description)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Discussions section
|
||||||
|
|
||||||
let user_discussions: Vec<&DiscussionDetail> = issue
|
let user_discussions: Vec<&DiscussionDetail> = issue
|
||||||
.discussions
|
.discussions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -709,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if user_discussions.is_empty() {
|
if user_discussions.is_empty() {
|
||||||
println!("{}", style("Discussions: (none)").dim());
|
println!("\n {}", Theme::muted().render("No discussions"));
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||||
);
|
);
|
||||||
println!();
|
|
||||||
|
|
||||||
for discussion in user_discussions {
|
for discussion in user_discussions {
|
||||||
let user_notes: Vec<&NoteDetail> =
|
let user_notes: Vec<&NoteDetail> =
|
||||||
@@ -723,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
|
|
||||||
if let Some(first_note) = user_notes.first() {
|
if let Some(first_note) = user_notes.first() {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
style(format!("@{}", first_note.author_username)).cyan(),
|
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||||
format_date(first_note.created_at)
|
format_date(first_note.created_at),
|
||||||
);
|
);
|
||||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
for reply in user_notes.iter().skip(1) {
|
for reply in user_notes.iter().skip(1) {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
style(format!("@{}", reply.author_username)).cyan(),
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||||
format_date(reply.created_at)
|
format_date(reply.created_at),
|
||||||
);
|
);
|
||||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_show_mr(mr: &MrDetail) {
|
pub fn print_show_mr(mr: &MrDetail) {
|
||||||
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
|
// Title line
|
||||||
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
|
let draft_prefix = if mr.draft {
|
||||||
println!("{}", style(&header).bold());
|
format!("{} ", Icons::mr_draft())
|
||||||
println!("{}", "━".repeat(header.len().min(80)));
|
} else {
|
||||||
println!();
|
String::new()
|
||||||
|
|
||||||
println!("Project: {}", style(&mr.project_path).cyan());
|
|
||||||
|
|
||||||
let state_styled = match mr.state.as_str() {
|
|
||||||
"opened" => style(&mr.state).green(),
|
|
||||||
"merged" => style(&mr.state).magenta(),
|
|
||||||
"closed" => style(&mr.state).red(),
|
|
||||||
_ => style(&mr.state).dim(),
|
|
||||||
};
|
};
|
||||||
println!("State: {}", state_styled);
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Branches: {} -> {}",
|
" MR !{}: {}{}",
|
||||||
style(&mr.source_branch).cyan(),
|
mr.iid,
|
||||||
style(&mr.target_branch).yellow()
|
draft_prefix,
|
||||||
|
Theme::bold().render(&mr.title),
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Author: @{}", mr.author_username);
|
// Details section
|
||||||
|
println!("{}", render::section_divider("Details"));
|
||||||
|
|
||||||
|
println!(" Project {}", Theme::info().render(&mr.project_path));
|
||||||
|
|
||||||
|
let (icon, state_style) = match mr.state.as_str() {
|
||||||
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||||
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||||
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||||
|
_ => (Icons::mr_opened(), Theme::dim()),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" State {}",
|
||||||
|
state_style.render(&format!("{icon} {}", mr.state))
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Branches {} -> {}",
|
||||||
|
Theme::info().render(&mr.source_branch),
|
||||||
|
Theme::warning().render(&mr.target_branch)
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(" Author @{}", mr.author_username);
|
||||||
|
|
||||||
if !mr.assignees.is_empty() {
|
if !mr.assignees.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"Assignees: {}",
|
" Assignees {}",
|
||||||
mr.assignees
|
mr.assignees
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| format!("@{}", a))
|
.map(|a| format!("@{a}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
@@ -784,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) {
|
|||||||
|
|
||||||
if !mr.reviewers.is_empty() {
|
if !mr.reviewers.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"Reviewers: {}",
|
" Reviewers {}",
|
||||||
mr.reviewers
|
mr.reviewers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| format!("@{}", r))
|
.map(|r| format!("@{r}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Created: {}", format_date(mr.created_at));
|
println!(
|
||||||
println!("Updated: {}", format_date(mr.updated_at));
|
" Created {} ({})",
|
||||||
|
format_date(mr.created_at),
|
||||||
|
render::format_relative_time_compact(mr.created_at),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Updated {} ({})",
|
||||||
|
format_date(mr.updated_at),
|
||||||
|
render::format_relative_time_compact(mr.updated_at),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(merged_at) = mr.merged_at {
|
if let Some(merged_at) = mr.merged_at {
|
||||||
println!("Merged: {}", format_date(merged_at));
|
println!(
|
||||||
|
" Merged {} ({})",
|
||||||
|
format_date(merged_at),
|
||||||
|
render::format_relative_time_compact(merged_at),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(closed_at) = mr.closed_at {
|
if let Some(closed_at) = mr.closed_at {
|
||||||
println!("Closed: {}", format_date(closed_at));
|
println!(
|
||||||
|
" Closed {} ({})",
|
||||||
|
format_date(closed_at),
|
||||||
|
render::format_relative_time_compact(closed_at),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if mr.labels.is_empty() {
|
if !mr.labels.is_empty() {
|
||||||
println!("Labels: {}", style("(none)").dim());
|
println!(
|
||||||
} else {
|
" Labels {}",
|
||||||
println!("Labels: {}", mr.labels.join(", "));
|
render::format_labels_bare(&mr.labels, mr.labels.len())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = &mr.web_url {
|
if let Some(url) = &mr.web_url {
|
||||||
println!("URL: {}", style(url).dim());
|
println!(" URL {}", Theme::muted().render(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Description section
|
||||||
|
println!("{}", render::section_divider("Description"));
|
||||||
println!("{}", style("Description:").bold());
|
|
||||||
if let Some(desc) = &mr.description {
|
if let Some(desc) = &mr.description {
|
||||||
let wrapped = wrap_text(desc, 76, " ");
|
let wrapped = wrap_text(desc, 72, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
} else {
|
} else {
|
||||||
println!(" {}", style("(no description)").dim());
|
println!(" {}", Theme::muted().render("(no description)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Discussions section
|
||||||
|
|
||||||
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
||||||
.discussions
|
.discussions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -833,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if user_discussions.is_empty() {
|
if user_discussions.is_empty() {
|
||||||
println!("{}", style("Discussions: (none)").dim());
|
println!("\n {}", Theme::muted().render("No discussions"));
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||||
);
|
);
|
||||||
println!();
|
|
||||||
|
|
||||||
for discussion in user_discussions {
|
for discussion in user_discussions {
|
||||||
let user_notes: Vec<&MrNoteDetail> =
|
let user_notes: Vec<&MrNoteDetail> =
|
||||||
@@ -851,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
style(format!("@{}", first_note.author_username)).cyan(),
|
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||||
format_date(first_note.created_at)
|
format_date(first_note.created_at),
|
||||||
);
|
);
|
||||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
for reply in user_notes.iter().skip(1) {
|
for reply in user_notes.iter().skip(1) {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
style(format!("@{}", reply.author_username)).cyan(),
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||||
format_date(reply.created_at)
|
format_date(reply.created_at),
|
||||||
);
|
);
|
||||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,39 +951,13 @@ fn print_diff_position(pos: &DiffNotePosition) {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {} {}{}",
|
" {} {}{}",
|
||||||
style("📍").dim(),
|
Theme::dim().render("\u{1f4cd}"),
|
||||||
style(file_path).yellow(),
|
Theme::warning().render(file_path),
|
||||||
style(line_str).dim()
|
Theme::dim().render(&line_str)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
|
|
||||||
let styled = console::style(text);
|
|
||||||
let Some(hex) = hex else { return styled };
|
|
||||||
let hex = hex.trim_start_matches('#');
|
|
||||||
if hex.len() != 6 {
|
|
||||||
return styled;
|
|
||||||
}
|
|
||||||
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
|
||||||
return styled;
|
|
||||||
};
|
|
||||||
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
|
||||||
return styled;
|
|
||||||
};
|
|
||||||
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
|
||||||
return styled;
|
|
||||||
};
|
|
||||||
styled.color256(ansi256_from_rgb(r, g, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
|
|
||||||
let ri = (u16::from(r) * 5 + 127) / 255;
|
|
||||||
let gi = (u16::from(g) * 5 + 127) / 255;
|
|
||||||
let bi = (u16::from(b) * 5 + 127) / 255;
|
|
||||||
(16 + 36 * ri + 6 * gi + bi) as u8
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct IssueDetailJson {
|
pub struct IssueDetailJson {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -931,12 +968,17 @@ pub struct IssueDetailJson {
|
|||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
pub closed_at: Option<String>,
|
||||||
|
pub confidential: bool,
|
||||||
pub web_url: Option<String>,
|
pub web_url: Option<String>,
|
||||||
pub project_path: String,
|
pub project_path: String,
|
||||||
|
pub references_full: String,
|
||||||
pub labels: Vec<String>,
|
pub labels: Vec<String>,
|
||||||
pub assignees: Vec<String>,
|
pub assignees: Vec<String>,
|
||||||
pub due_date: Option<String>,
|
pub due_date: Option<String>,
|
||||||
pub milestone: Option<String>,
|
pub milestone: Option<String>,
|
||||||
|
pub user_notes_count: i64,
|
||||||
|
pub merge_requests_count: usize,
|
||||||
pub closing_merge_requests: Vec<ClosingMrRefJson>,
|
pub closing_merge_requests: Vec<ClosingMrRefJson>,
|
||||||
pub discussions: Vec<DiscussionDetailJson>,
|
pub discussions: Vec<DiscussionDetailJson>,
|
||||||
pub status_name: Option<String>,
|
pub status_name: Option<String>,
|
||||||
@@ -980,12 +1022,17 @@ impl From<&IssueDetail> for IssueDetailJson {
|
|||||||
author_username: issue.author_username.clone(),
|
author_username: issue.author_username.clone(),
|
||||||
created_at: ms_to_iso(issue.created_at),
|
created_at: ms_to_iso(issue.created_at),
|
||||||
updated_at: ms_to_iso(issue.updated_at),
|
updated_at: ms_to_iso(issue.updated_at),
|
||||||
|
closed_at: issue.closed_at.clone(),
|
||||||
|
confidential: issue.confidential,
|
||||||
web_url: issue.web_url.clone(),
|
web_url: issue.web_url.clone(),
|
||||||
project_path: issue.project_path.clone(),
|
project_path: issue.project_path.clone(),
|
||||||
|
references_full: issue.references_full.clone(),
|
||||||
labels: issue.labels.clone(),
|
labels: issue.labels.clone(),
|
||||||
assignees: issue.assignees.clone(),
|
assignees: issue.assignees.clone(),
|
||||||
due_date: issue.due_date.clone(),
|
due_date: issue.due_date.clone(),
|
||||||
milestone: issue.milestone.clone(),
|
milestone: issue.milestone.clone(),
|
||||||
|
user_notes_count: issue.user_notes_count,
|
||||||
|
merge_requests_count: issue.merge_requests_count,
|
||||||
closing_merge_requests: issue
|
closing_merge_requests: issue
|
||||||
.closing_merge_requests
|
.closing_merge_requests
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1171,10 +1218,177 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seed_second_project(conn: &Connection) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (2, 101, 'other/repo', 'https://gitlab.example.com/other', 1000, 2000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_discussion_with_notes(
|
||||||
|
conn: &Connection,
|
||||||
|
issue_id: i64,
|
||||||
|
project_id: i64,
|
||||||
|
user_notes: usize,
|
||||||
|
system_notes: usize,
|
||||||
|
) {
|
||||||
|
let disc_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(MAX(id), 0) + 1 FROM discussions",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, 'Issue', 1000, 2000, 2000)",
|
||||||
|
rusqlite::params![disc_id, format!("disc-{}", disc_id), project_id, issue_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..user_notes {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
|
||||||
|
VALUES (?1, ?2, ?3, 'user1', 'comment', 1000, 2000, 2000, 0, ?4)",
|
||||||
|
rusqlite::params![1000 + disc_id * 100 + i as i64, disc_id, project_id, i as i64],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for i in 0..system_notes {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
|
||||||
|
VALUES (?1, ?2, ?3, 'system', 'status changed', 1000, 2000, 2000, 1, ?4)",
|
||||||
|
rusqlite::params![2000 + disc_id * 100 + i as i64, disc_id, project_id, (user_notes + i) as i64],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- find_issue tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_basic() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
let row = find_issue(&conn, 10, None).unwrap();
|
||||||
|
assert_eq!(row.iid, 10);
|
||||||
|
assert_eq!(row.title, "Test issue");
|
||||||
|
assert_eq!(row.state, "opened");
|
||||||
|
assert_eq!(row.author_username, "author");
|
||||||
|
assert_eq!(row.project_path, "group/repo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_with_project_filter() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
let row = find_issue(&conn, 10, Some("group/repo")).unwrap();
|
||||||
|
assert_eq!(row.iid, 10);
|
||||||
|
assert_eq!(row.project_path, "group/repo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_not_found() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
let err = find_issue(&conn, 999, None).unwrap_err();
|
||||||
|
assert!(matches!(err, LoreError::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_wrong_project_filter() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
seed_second_project(&conn);
|
||||||
|
// Issue 10 only exists in project 1, not project 2
|
||||||
|
let err = find_issue(&conn, 10, Some("other/repo")).unwrap_err();
|
||||||
|
assert!(matches!(err, LoreError::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_ambiguous_without_project() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn); // issue iid=10 in project 1
|
||||||
|
seed_second_project(&conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
||||||
|
created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let err = find_issue(&conn, 10, None).unwrap_err();
|
||||||
|
assert!(matches!(err, LoreError::Ambiguous(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_ambiguous_resolved_with_project() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
seed_second_project(&conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
||||||
|
created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let row = find_issue(&conn, 10, Some("other/repo")).unwrap();
|
||||||
|
assert_eq!(row.title, "Same iid different project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_user_notes_count_zero() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
let row = find_issue(&conn, 10, None).unwrap();
|
||||||
|
assert_eq!(row.user_notes_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_user_notes_count_excludes_system() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
// 2 user notes + 3 system notes = should count only 2
|
||||||
|
seed_discussion_with_notes(&conn, 1, 1, 2, 3);
|
||||||
|
let row = find_issue(&conn, 10, None).unwrap();
|
||||||
|
assert_eq!(row.user_notes_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_user_notes_count_across_discussions() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
seed_discussion_with_notes(&conn, 1, 1, 3, 0); // 3 user notes
|
||||||
|
seed_discussion_with_notes(&conn, 1, 1, 1, 2); // 1 user note + 2 system
|
||||||
|
let row = find_issue(&conn, 10, None).unwrap();
|
||||||
|
assert_eq!(row.user_notes_count, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_issue_notes_count_ignores_other_issues() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_issue(&conn);
|
||||||
|
// Add a second issue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
||||||
|
created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (2, 201, 20, 1, 'Other issue', 'opened', 'author', 1000, 2000, 2000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Notes on issue 2, not issue 1
|
||||||
|
seed_discussion_with_notes(&conn, 2, 1, 5, 0);
|
||||||
|
let row = find_issue(&conn, 10, None).unwrap();
|
||||||
|
assert_eq!(row.user_notes_count, 0); // Issue 10 has no notes
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ansi256_from_rgb() {
|
fn test_ansi256_from_rgb() {
|
||||||
assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
|
// Moved to render.rs — keeping basic hex sanity check
|
||||||
assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
|
let result = render::style_with_hex("test", Some("#ff0000"));
|
||||||
|
assert!(!result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::{self, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -79,33 +79,43 @@ pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> R
|
|||||||
|
|
||||||
let mut result = StatsResult::default();
|
let mut result = StatsResult::default();
|
||||||
|
|
||||||
result.documents.total = count_query(&conn, "SELECT COUNT(*) FROM documents")?;
|
// Single-scan conditional aggregate: 5 sequential COUNT(*) → 1 table scan
|
||||||
result.documents.issues = count_query(
|
let (total, issues, mrs, discussions, truncated) = conn
|
||||||
&conn,
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'issue'",
|
"SELECT COUNT(*),
|
||||||
)?;
|
COALESCE(SUM(CASE WHEN source_type = 'issue' THEN 1 END), 0),
|
||||||
result.documents.merge_requests = count_query(
|
COALESCE(SUM(CASE WHEN source_type = 'merge_request' THEN 1 END), 0),
|
||||||
&conn,
|
COALESCE(SUM(CASE WHEN source_type = 'discussion' THEN 1 END), 0),
|
||||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'merge_request'",
|
COALESCE(SUM(CASE WHEN is_truncated = 1 THEN 1 END), 0)
|
||||||
)?;
|
FROM documents",
|
||||||
result.documents.discussions = count_query(
|
[],
|
||||||
&conn,
|
|row| {
|
||||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'discussion'",
|
Ok((
|
||||||
)?;
|
row.get::<_, i64>(0)?,
|
||||||
result.documents.truncated = count_query(
|
row.get::<_, i64>(1)?,
|
||||||
&conn,
|
row.get::<_, i64>(2)?,
|
||||||
"SELECT COUNT(*) FROM documents WHERE is_truncated = 1",
|
row.get::<_, i64>(3)?,
|
||||||
)?;
|
row.get::<_, i64>(4)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or((0, 0, 0, 0, 0));
|
||||||
|
result.documents.total = total;
|
||||||
|
result.documents.issues = issues;
|
||||||
|
result.documents.merge_requests = mrs;
|
||||||
|
result.documents.discussions = discussions;
|
||||||
|
result.documents.truncated = truncated;
|
||||||
|
|
||||||
if table_exists(&conn, "embedding_metadata") {
|
if table_exists(&conn, "embedding_metadata") {
|
||||||
let embedded = count_query(
|
// Single scan: COUNT(DISTINCT) + COUNT(*) in one pass
|
||||||
&conn,
|
let (embedded, chunks) = conn
|
||||||
"SELECT COUNT(DISTINCT document_id) FROM embedding_metadata WHERE last_error IS NULL",
|
.query_row(
|
||||||
)?;
|
"SELECT COUNT(DISTINCT document_id), COUNT(*)
|
||||||
let chunks = count_query(
|
FROM embedding_metadata WHERE last_error IS NULL",
|
||||||
&conn,
|
[],
|
||||||
"SELECT COUNT(*) FROM embedding_metadata WHERE last_error IS NULL",
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
)?;
|
)
|
||||||
|
.unwrap_or((0, 0));
|
||||||
result.embeddings.embedded_documents = embedded;
|
result.embeddings.embedded_documents = embedded;
|
||||||
result.embeddings.total_chunks = chunks;
|
result.embeddings.total_chunks = chunks;
|
||||||
result.embeddings.coverage_pct = if result.documents.total > 0 {
|
result.embeddings.coverage_pct = if result.documents.total > 0 {
|
||||||
@@ -115,41 +125,57 @@ pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> R
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts")?;
|
// FTS5 shadow table is a regular B-tree with one row per document —
|
||||||
|
// 19x faster than scanning the virtual table for COUNT(*)
|
||||||
|
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts_docsize")?;
|
||||||
|
|
||||||
result.queues.dirty_sources = count_query(
|
// Single scan: 2 conditional counts on dirty_sources
|
||||||
&conn,
|
let (ds_pending, ds_failed) = conn
|
||||||
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NULL",
|
.query_row(
|
||||||
)?;
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
||||||
result.queues.dirty_sources_failed = count_query(
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
|
||||||
&conn,
|
FROM dirty_sources",
|
||||||
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NOT NULL",
|
[],
|
||||||
)?;
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
result.queues.dirty_sources = ds_pending;
|
||||||
|
result.queues.dirty_sources_failed = ds_failed;
|
||||||
|
|
||||||
if table_exists(&conn, "pending_discussion_fetches") {
|
if table_exists(&conn, "pending_discussion_fetches") {
|
||||||
result.queues.pending_discussion_fetches = count_query(
|
let (pdf_pending, pdf_failed) = conn
|
||||||
&conn,
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_discussion_fetches WHERE last_error IS NULL",
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
||||||
)?;
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
|
||||||
result.queues.pending_discussion_fetches_failed = count_query(
|
FROM pending_discussion_fetches",
|
||||||
&conn,
|
[],
|
||||||
"SELECT COUNT(*) FROM pending_discussion_fetches WHERE last_error IS NOT NULL",
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
)?;
|
)
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
result.queues.pending_discussion_fetches = pdf_pending;
|
||||||
|
result.queues.pending_discussion_fetches_failed = pdf_failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if table_exists(&conn, "pending_dependent_fetches") {
|
if table_exists(&conn, "pending_dependent_fetches") {
|
||||||
result.queues.pending_dependent_fetches = count_query(
|
let (pf_pending, pf_failed, pf_stuck) = conn
|
||||||
&conn,
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE last_error IS NULL",
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
||||||
)?;
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0),
|
||||||
result.queues.pending_dependent_fetches_failed = count_query(
|
COALESCE(SUM(CASE WHEN locked_at IS NOT NULL THEN 1 END), 0)
|
||||||
&conn,
|
FROM pending_dependent_fetches",
|
||||||
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE last_error IS NOT NULL",
|
[],
|
||||||
)?;
|
|row| {
|
||||||
result.queues.pending_dependent_fetches_stuck = count_query(
|
Ok((
|
||||||
&conn,
|
row.get::<_, i64>(0)?,
|
||||||
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL",
|
row.get::<_, i64>(1)?,
|
||||||
)?;
|
row.get::<_, i64>(2)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or((0, 0, 0));
|
||||||
|
result.queues.pending_dependent_fetches = pf_pending;
|
||||||
|
result.queues.pending_dependent_fetches_failed = pf_failed;
|
||||||
|
result.queues.pending_dependent_fetches_stuck = pf_stuck;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::field_reassign_with_default)]
|
#[allow(clippy::field_reassign_with_default)]
|
||||||
@@ -296,124 +322,206 @@ fn table_exists(conn: &Connection, table: &str) -> bool {
|
|||||||
> 0
|
> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn section(title: &str) {
|
||||||
|
println!("{}", render::section_divider(title));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_stats(result: &StatsResult) {
|
pub fn print_stats(result: &StatsResult) {
|
||||||
println!("{}", style("Documents").cyan().bold());
|
section("Documents");
|
||||||
println!(" Total: {}", result.documents.total);
|
let mut parts = vec![format!(
|
||||||
println!(" Issues: {}", result.documents.issues);
|
"{} total",
|
||||||
println!(" Merge Requests: {}", result.documents.merge_requests);
|
render::format_number(result.documents.total)
|
||||||
println!(" Discussions: {}", result.documents.discussions);
|
)];
|
||||||
|
if result.documents.issues > 0 {
|
||||||
|
parts.push(format!(
|
||||||
|
"{} issues",
|
||||||
|
render::format_number(result.documents.issues)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if result.documents.merge_requests > 0 {
|
||||||
|
parts.push(format!(
|
||||||
|
"{} MRs",
|
||||||
|
render::format_number(result.documents.merge_requests)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if result.documents.discussions > 0 {
|
||||||
|
parts.push(format!(
|
||||||
|
"{} discussions",
|
||||||
|
render::format_number(result.documents.discussions)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
println!(" {}", parts.join(" \u{b7} "));
|
||||||
if result.documents.truncated > 0 {
|
if result.documents.truncated > 0 {
|
||||||
println!(
|
println!(
|
||||||
" Truncated: {}",
|
" {}",
|
||||||
style(result.documents.truncated).yellow()
|
Theme::warning().render(&format!(
|
||||||
|
"{} truncated",
|
||||||
|
render::format_number(result.documents.truncated)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("{}", style("Search Index").cyan().bold());
|
section("Search Index");
|
||||||
println!(" FTS indexed: {}", result.fts.indexed);
|
|
||||||
println!(
|
println!(
|
||||||
" Embedding coverage: {:.1}% ({}/{})",
|
" {} FTS indexed",
|
||||||
result.embeddings.coverage_pct,
|
render::format_number(result.fts.indexed)
|
||||||
result.embeddings.embedded_documents,
|
);
|
||||||
result.documents.total
|
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
|
||||||
|
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||||
|
} else if result.embeddings.coverage_pct >= 50.0 {
|
||||||
|
Theme::warning().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||||
|
} else {
|
||||||
|
Theme::error().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {} embedding coverage ({}/{})",
|
||||||
|
coverage_color,
|
||||||
|
render::format_number(result.embeddings.embedded_documents),
|
||||||
|
render::format_number(result.documents.total),
|
||||||
);
|
);
|
||||||
if result.embeddings.total_chunks > 0 {
|
if result.embeddings.total_chunks > 0 {
|
||||||
println!(" Total chunks: {}", result.embeddings.total_chunks);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("{}", style("Queues").cyan().bold());
|
|
||||||
println!(
|
|
||||||
" Dirty sources: {} pending, {} failed",
|
|
||||||
result.queues.dirty_sources, result.queues.dirty_sources_failed
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" Discussion fetch: {} pending, {} failed",
|
|
||||||
result.queues.pending_discussion_fetches, result.queues.pending_discussion_fetches_failed
|
|
||||||
);
|
|
||||||
if result.queues.pending_dependent_fetches > 0
|
|
||||||
|| result.queues.pending_dependent_fetches_failed > 0
|
|
||||||
|| result.queues.pending_dependent_fetches_stuck > 0
|
|
||||||
{
|
|
||||||
println!(
|
println!(
|
||||||
" Dependent fetch: {} pending, {} failed, {} stuck",
|
" {}",
|
||||||
result.queues.pending_dependent_fetches,
|
Theme::dim().render(&format!(
|
||||||
result.queues.pending_dependent_fetches_failed,
|
"{} chunks",
|
||||||
result.queues.pending_dependent_fetches_stuck
|
render::format_number(result.embeddings.total_chunks)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queues: only show if there's anything to report
|
||||||
|
let has_queue_activity = result.queues.dirty_sources > 0
|
||||||
|
|| result.queues.dirty_sources_failed > 0
|
||||||
|
|| result.queues.pending_discussion_fetches > 0
|
||||||
|
|| result.queues.pending_discussion_fetches_failed > 0
|
||||||
|
|| result.queues.pending_dependent_fetches > 0
|
||||||
|
|| result.queues.pending_dependent_fetches_failed > 0;
|
||||||
|
|
||||||
|
if has_queue_activity {
|
||||||
|
section("Queues");
|
||||||
|
if result.queues.dirty_sources > 0 || result.queues.dirty_sources_failed > 0 {
|
||||||
|
let mut q = Vec::new();
|
||||||
|
if result.queues.dirty_sources > 0 {
|
||||||
|
q.push(format!("{} pending", result.queues.dirty_sources));
|
||||||
|
}
|
||||||
|
if result.queues.dirty_sources_failed > 0 {
|
||||||
|
q.push(
|
||||||
|
Theme::error()
|
||||||
|
.render(&format!("{} failed", result.queues.dirty_sources_failed)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(" dirty sources: {}", q.join(", "));
|
||||||
|
}
|
||||||
|
if result.queues.pending_discussion_fetches > 0
|
||||||
|
|| result.queues.pending_discussion_fetches_failed > 0
|
||||||
|
{
|
||||||
|
let mut q = Vec::new();
|
||||||
|
if result.queues.pending_discussion_fetches > 0 {
|
||||||
|
q.push(format!(
|
||||||
|
"{} pending",
|
||||||
|
result.queues.pending_discussion_fetches
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if result.queues.pending_discussion_fetches_failed > 0 {
|
||||||
|
q.push(Theme::error().render(&format!(
|
||||||
|
"{} failed",
|
||||||
|
result.queues.pending_discussion_fetches_failed
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
println!(" discussion fetch: {}", q.join(", "));
|
||||||
|
}
|
||||||
|
if result.queues.pending_dependent_fetches > 0
|
||||||
|
|| result.queues.pending_dependent_fetches_failed > 0
|
||||||
|
{
|
||||||
|
let mut q = Vec::new();
|
||||||
|
if result.queues.pending_dependent_fetches > 0 {
|
||||||
|
q.push(format!(
|
||||||
|
"{} pending",
|
||||||
|
result.queues.pending_dependent_fetches
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if result.queues.pending_dependent_fetches_failed > 0 {
|
||||||
|
q.push(Theme::error().render(&format!(
|
||||||
|
"{} failed",
|
||||||
|
result.queues.pending_dependent_fetches_failed
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if result.queues.pending_dependent_fetches_stuck > 0 {
|
||||||
|
q.push(Theme::warning().render(&format!(
|
||||||
|
"{} stuck",
|
||||||
|
result.queues.pending_dependent_fetches_stuck
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
println!(" dependent fetch: {}", q.join(", "));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
section("Queues");
|
||||||
|
println!(" {}", Theme::success().render("all clear"));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref integrity) = result.integrity {
|
if let Some(ref integrity) = result.integrity {
|
||||||
println!();
|
section("Integrity");
|
||||||
let status = if integrity.ok {
|
if integrity.ok {
|
||||||
style("OK").green().bold()
|
println!(
|
||||||
|
" {} all checks passed",
|
||||||
|
Theme::success().render("\u{2713}")
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
style("ISSUES FOUND").red().bold()
|
if integrity.fts_doc_mismatch {
|
||||||
};
|
println!(
|
||||||
println!("{} Integrity: {}", style("Check").cyan().bold(), status);
|
" {} FTS/document count mismatch",
|
||||||
|
Theme::error().render("\u{2717}")
|
||||||
if integrity.fts_doc_mismatch {
|
);
|
||||||
println!(" {} FTS/document count mismatch", style("!").red());
|
}
|
||||||
}
|
if integrity.orphan_embeddings > 0 {
|
||||||
if integrity.orphan_embeddings > 0 {
|
println!(
|
||||||
println!(
|
" {} {} orphan embeddings",
|
||||||
" {} {} orphan embeddings",
|
Theme::error().render("\u{2717}"),
|
||||||
style("!").red(),
|
integrity.orphan_embeddings
|
||||||
integrity.orphan_embeddings
|
);
|
||||||
);
|
}
|
||||||
}
|
if integrity.stale_metadata > 0 {
|
||||||
if integrity.stale_metadata > 0 {
|
println!(
|
||||||
println!(
|
" {} {} stale embedding metadata",
|
||||||
" {} {} stale embedding metadata",
|
Theme::error().render("\u{2717}"),
|
||||||
style("!").red(),
|
integrity.stale_metadata
|
||||||
integrity.stale_metadata
|
);
|
||||||
);
|
}
|
||||||
}
|
let orphan_events = integrity.orphan_state_events
|
||||||
let orphan_events = integrity.orphan_state_events
|
+ integrity.orphan_label_events
|
||||||
+ integrity.orphan_label_events
|
+ integrity.orphan_milestone_events;
|
||||||
+ integrity.orphan_milestone_events;
|
if orphan_events > 0 {
|
||||||
if orphan_events > 0 {
|
println!(
|
||||||
println!(
|
" {} {} orphan resource events",
|
||||||
" {} {} orphan resource events (state: {}, label: {}, milestone: {})",
|
Theme::error().render("\u{2717}"),
|
||||||
style("!").red(),
|
orphan_events
|
||||||
orphan_events,
|
);
|
||||||
integrity.orphan_state_events,
|
}
|
||||||
integrity.orphan_label_events,
|
if integrity.queue_stuck_locks > 0 {
|
||||||
integrity.orphan_milestone_events
|
println!(
|
||||||
);
|
" {} {} stuck queue locks",
|
||||||
}
|
Theme::warning().render("!"),
|
||||||
if integrity.queue_stuck_locks > 0 {
|
integrity.queue_stuck_locks
|
||||||
println!(
|
);
|
||||||
" {} {} stuck queue locks",
|
}
|
||||||
style("!").yellow(),
|
|
||||||
integrity.queue_stuck_locks
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if integrity.queue_max_attempts > 3 {
|
|
||||||
println!(
|
|
||||||
" {} max queue retry attempts: {}",
|
|
||||||
style("!").yellow(),
|
|
||||||
integrity.queue_max_attempts
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref repair) = integrity.repair {
|
if let Some(ref repair) = integrity.repair {
|
||||||
println!();
|
println!();
|
||||||
if repair.dry_run {
|
if repair.dry_run {
|
||||||
println!(
|
println!(
|
||||||
"{} {}",
|
" {} {}",
|
||||||
style("Repair").cyan().bold(),
|
Theme::bold().render("Repair"),
|
||||||
style("(dry run - no changes made)").yellow()
|
Theme::warning().render("(dry run)")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("{}", style("Repair").cyan().bold());
|
println!(" {}", Theme::bold().render("Repair"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = if repair.dry_run {
|
let action = if repair.dry_run {
|
||||||
style("would fix").yellow()
|
Theme::warning().render("would fix")
|
||||||
} else {
|
} else {
|
||||||
style("fixed").green()
|
Theme::success().render("fixed")
|
||||||
};
|
};
|
||||||
|
|
||||||
if repair.fts_rebuilt {
|
if repair.fts_rebuilt {
|
||||||
@@ -427,15 +535,17 @@ pub fn print_stats(result: &StatsResult) {
|
|||||||
}
|
}
|
||||||
if repair.stale_cleared > 0 {
|
if repair.stale_cleared > 0 {
|
||||||
println!(
|
println!(
|
||||||
" {} {} stale metadata entries cleared",
|
" {} {} stale metadata cleared",
|
||||||
action, repair.stale_cleared
|
action, repair.stale_cleared
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 {
|
if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 {
|
||||||
println!(" No issues to repair.");
|
println!(" {}", Theme::dim().render("nothing to repair"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
use console::style;
|
use crate::cli::render::{self, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -166,27 +166,6 @@ fn format_duration(ms: i64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_number(n: i64) -> String {
|
|
||||||
let is_negative = n < 0;
|
|
||||||
let abs_n = n.unsigned_abs();
|
|
||||||
let s = abs_n.to_string();
|
|
||||||
let chars: Vec<char> = s.chars().collect();
|
|
||||||
let mut result = String::new();
|
|
||||||
|
|
||||||
if is_negative {
|
|
||||||
result.push('-');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, c) in chars.iter().enumerate() {
|
|
||||||
if i > 0 && (chars.len() - i).is_multiple_of(3) {
|
|
||||||
result.push(',');
|
|
||||||
}
|
|
||||||
result.push(*c);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct SyncStatusJsonOutput {
|
struct SyncStatusJsonOutput {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -293,14 +272,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_sync_status(result: &SyncStatusResult) {
|
pub fn print_sync_status(result: &SyncStatusResult) {
|
||||||
println!("{}", style("Recent Sync Runs").bold().underlined());
|
println!("{}", Theme::bold().underline().render("Recent Sync Runs"));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if result.runs.is_empty() {
|
if result.runs.is_empty() {
|
||||||
println!(" {}", style("No sync runs recorded yet.").dim());
|
println!(" {}", Theme::dim().render("No sync runs recorded yet."));
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
style("Run 'lore sync' or 'lore ingest' to start.").dim()
|
Theme::dim().render("Run 'lore sync' or 'lore ingest' to start.")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
for run in &result.runs {
|
for run in &result.runs {
|
||||||
@@ -310,16 +289,16 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
println!("{}", style("Cursor Positions").bold().underlined());
|
println!("{}", Theme::bold().underline().render("Cursor Positions"));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if result.cursors.is_empty() {
|
if result.cursors.is_empty() {
|
||||||
println!(" {}", style("No cursors recorded yet.").dim());
|
println!(" {}", Theme::dim().render("No cursors recorded yet."));
|
||||||
} else {
|
} else {
|
||||||
for cursor in &result.cursors {
|
for cursor in &result.cursors {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} ({}):",
|
||||||
style(&cursor.project_path).cyan(),
|
Theme::info().render(&cursor.project_path),
|
||||||
cursor.resource_type
|
cursor.resource_type
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,7 +307,10 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
|||||||
println!(" Last updated_at: {}", ms_to_iso(ts));
|
println!(" Last updated_at: {}", ms_to_iso(ts));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!(" Last updated_at: {}", style("Not started").dim());
|
println!(
|
||||||
|
" Last updated_at: {}",
|
||||||
|
Theme::dim().render("Not started")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,40 +322,39 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
println!("{}", style("Data Summary").bold().underlined());
|
println!("{}", Theme::bold().underline().render("Data Summary"));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" Issues: {}",
|
" Issues: {}",
|
||||||
style(format_number(result.summary.issue_count)).bold()
|
Theme::bold().render(&render::format_number(result.summary.issue_count))
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" MRs: {}",
|
" MRs: {}",
|
||||||
style(format_number(result.summary.mr_count)).bold()
|
Theme::bold().render(&render::format_number(result.summary.mr_count))
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" Discussions: {}",
|
" Discussions: {}",
|
||||||
style(format_number(result.summary.discussion_count)).bold()
|
Theme::bold().render(&render::format_number(result.summary.discussion_count))
|
||||||
);
|
);
|
||||||
|
|
||||||
let user_notes = result.summary.note_count - result.summary.system_note_count;
|
let user_notes = result.summary.note_count - result.summary.system_note_count;
|
||||||
println!(
|
println!(
|
||||||
" Notes: {} {}",
|
" Notes: {} {}",
|
||||||
style(format_number(user_notes)).bold(),
|
Theme::bold().render(&render::format_number(user_notes)),
|
||||||
style(format!(
|
Theme::dim().render(&format!(
|
||||||
"(excluding {} system)",
|
"(excluding {} system)",
|
||||||
format_number(result.summary.system_note_count)
|
render::format_number(result.summary.system_note_count)
|
||||||
))
|
))
|
||||||
.dim()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_run_line(run: &SyncRunInfo) {
|
fn print_run_line(run: &SyncRunInfo) {
|
||||||
let status_styled = match run.status.as_str() {
|
let status_styled = match run.status.as_str() {
|
||||||
"succeeded" => style(&run.status).green(),
|
"succeeded" => Theme::success().render(&run.status),
|
||||||
"failed" => style(&run.status).red(),
|
"failed" => Theme::error().render(&run.status),
|
||||||
"running" => style(&run.status).yellow(),
|
"running" => Theme::warning().render(&run.status),
|
||||||
_ => style(&run.status).dim(),
|
_ => Theme::dim().render(&run.status),
|
||||||
};
|
};
|
||||||
|
|
||||||
let run_label = run
|
let run_label = run
|
||||||
@@ -386,9 +367,9 @@ fn print_run_line(run: &SyncRunInfo) {
|
|||||||
let time = format_full_datetime(run.started_at);
|
let time = format_full_datetime(run.started_at);
|
||||||
|
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!("{}", style(run_label).bold()),
|
Theme::bold().render(&run_label),
|
||||||
format!("{status_styled}"),
|
status_styled,
|
||||||
format!("{}", style(&run.command).dim()),
|
Theme::dim().render(&run.command),
|
||||||
time,
|
time,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -403,16 +384,13 @@ fn print_run_line(run: &SyncRunInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if run.total_errors > 0 {
|
if run.total_errors > 0 {
|
||||||
parts.push(format!(
|
parts.push(Theme::error().render(&format!("{} errors", run.total_errors)));
|
||||||
"{}",
|
|
||||||
style(format!("{} errors", run.total_errors)).red()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" {}", parts.join(" | "));
|
println!(" {}", parts.join(" | "));
|
||||||
|
|
||||||
if let Some(error) = &run.error {
|
if let Some(error) = &run.error {
|
||||||
println!(" {}", style(error).red());
|
println!(" {}", Theme::error().render(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +426,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_number_adds_thousands_separators() {
|
fn format_number_adds_thousands_separators() {
|
||||||
assert_eq!(format_number(1000), "1,000");
|
assert_eq!(render::format_number(1000), "1,000");
|
||||||
assert_eq!(format_number(1234567), "1,234,567");
|
assert_eq!(render::format_number(1234567), "1,234,567");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use console::{Alignment, pad_str, style};
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
|
use crate::cli::progress::stage_spinner_v2;
|
||||||
use crate::core::db::create_connection;
|
use crate::core::db::create_connection;
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
use crate::core::paths::get_db_path;
|
use crate::core::paths::get_db_path;
|
||||||
@@ -12,7 +13,8 @@ use crate::core::timeline::{
|
|||||||
};
|
};
|
||||||
use crate::core::timeline_collect::collect_events;
|
use crate::core::timeline_collect::collect_events;
|
||||||
use crate::core::timeline_expand::expand_timeline;
|
use crate::core::timeline_expand::expand_timeline;
|
||||||
use crate::core::timeline_seed::seed_timeline;
|
use crate::core::timeline_seed::{seed_timeline, seed_timeline_direct};
|
||||||
|
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||||
|
|
||||||
/// Parameters for running the timeline pipeline.
|
/// Parameters for running the timeline pipeline.
|
||||||
pub struct TimelineParams {
|
pub struct TimelineParams {
|
||||||
@@ -20,15 +22,53 @@ pub struct TimelineParams {
|
|||||||
pub project: Option<String>,
|
pub project: Option<String>,
|
||||||
pub since: Option<String>,
|
pub since: Option<String>,
|
||||||
pub depth: u32,
|
pub depth: u32,
|
||||||
pub expand_mentions: bool,
|
pub no_mentions: bool,
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
pub max_seeds: usize,
|
pub max_seeds: usize,
|
||||||
pub max_entities: usize,
|
pub max_entities: usize,
|
||||||
pub max_evidence: usize,
|
pub max_evidence: usize,
|
||||||
|
pub robot_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed timeline query: either a search string or a direct entity reference.
|
||||||
|
enum TimelineQuery {
|
||||||
|
Search(String),
|
||||||
|
EntityDirect { entity_type: String, iid: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the timeline query for entity-direct patterns.
|
||||||
|
///
|
||||||
|
/// Recognized patterns (case-insensitive prefix):
|
||||||
|
/// - `issue:N`, `i:N` -> issue
|
||||||
|
/// - `mr:N`, `m:N` -> merge_request
|
||||||
|
/// - Anything else -> search query
|
||||||
|
fn parse_timeline_query(query: &str) -> TimelineQuery {
|
||||||
|
let query = query.trim();
|
||||||
|
if let Some((prefix, rest)) = query.split_once(':') {
|
||||||
|
let prefix_lower = prefix.to_ascii_lowercase();
|
||||||
|
if let Ok(iid) = rest.trim().parse::<i64>() {
|
||||||
|
match prefix_lower.as_str() {
|
||||||
|
"issue" | "i" => {
|
||||||
|
return TimelineQuery::EntityDirect {
|
||||||
|
entity_type: "issue".to_owned(),
|
||||||
|
iid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"mr" | "m" => {
|
||||||
|
return TimelineQuery::EntityDirect {
|
||||||
|
entity_type: "merge_request".to_owned(),
|
||||||
|
iid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimelineQuery::Search(query.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT.
|
/// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT.
|
||||||
pub fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
||||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
let conn = create_connection(&db_path)?;
|
let conn = create_connection(&db_path)?;
|
||||||
|
|
||||||
@@ -50,37 +90,90 @@ pub fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Timeline
|
|||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
// Stage 1+2: SEED + HYDRATE
|
// Parse query for entity-direct syntax (issue:N, mr:N, i:N, m:N)
|
||||||
let seed_result = seed_timeline(
|
let parsed_query = parse_timeline_query(¶ms.query);
|
||||||
&conn,
|
|
||||||
¶ms.query,
|
let seed_result = match parsed_query {
|
||||||
project_id,
|
TimelineQuery::EntityDirect { entity_type, iid } => {
|
||||||
since_ms,
|
// Direct seeding: synchronous, no Ollama needed
|
||||||
params.max_seeds,
|
let spinner = stage_spinner_v2(
|
||||||
params.max_evidence,
|
Icons::search(),
|
||||||
)?;
|
"Resolve",
|
||||||
|
"Resolving entity...",
|
||||||
|
params.robot_mode,
|
||||||
|
);
|
||||||
|
let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?;
|
||||||
|
spinner.finish_and_clear();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
TimelineQuery::Search(ref query) => {
|
||||||
|
// Construct OllamaClient for hybrid search (same pattern as run_search)
|
||||||
|
let ollama_cfg = &config.embedding;
|
||||||
|
let client = OllamaClient::new(OllamaConfig {
|
||||||
|
base_url: ollama_cfg.base_url.clone(),
|
||||||
|
model: ollama_cfg.model.clone(),
|
||||||
|
..OllamaConfig::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
|
||||||
|
let spinner = stage_spinner_v2(
|
||||||
|
Icons::search(),
|
||||||
|
"Seed",
|
||||||
|
"Seeding timeline...",
|
||||||
|
params.robot_mode,
|
||||||
|
);
|
||||||
|
let result = seed_timeline(
|
||||||
|
&conn,
|
||||||
|
Some(&client),
|
||||||
|
query,
|
||||||
|
project_id,
|
||||||
|
since_ms,
|
||||||
|
params.max_seeds,
|
||||||
|
params.max_evidence,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
spinner.finish_and_clear();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Stage 3: EXPAND
|
// Stage 3: EXPAND
|
||||||
|
let spinner = stage_spinner_v2(
|
||||||
|
Icons::sync(),
|
||||||
|
"Expand",
|
||||||
|
"Expanding cross-references...",
|
||||||
|
params.robot_mode,
|
||||||
|
);
|
||||||
let expand_result = expand_timeline(
|
let expand_result = expand_timeline(
|
||||||
&conn,
|
&conn,
|
||||||
&seed_result.seed_entities,
|
&seed_result.seed_entities,
|
||||||
params.depth,
|
params.depth,
|
||||||
params.expand_mentions,
|
!params.no_mentions,
|
||||||
params.max_entities,
|
params.max_entities,
|
||||||
)?;
|
)?;
|
||||||
|
spinner.finish_and_clear();
|
||||||
|
|
||||||
// Stage 4: COLLECT
|
// Stage 4: COLLECT
|
||||||
|
let spinner = stage_spinner_v2(
|
||||||
|
Icons::sync(),
|
||||||
|
"Collect",
|
||||||
|
"Collecting events...",
|
||||||
|
params.robot_mode,
|
||||||
|
);
|
||||||
let (events, total_before_limit) = collect_events(
|
let (events, total_before_limit) = collect_events(
|
||||||
&conn,
|
&conn,
|
||||||
&seed_result.seed_entities,
|
&seed_result.seed_entities,
|
||||||
&expand_result.expanded_entities,
|
&expand_result.expanded_entities,
|
||||||
&seed_result.evidence_notes,
|
&seed_result.evidence_notes,
|
||||||
|
&seed_result.matched_discussions,
|
||||||
since_ms,
|
since_ms,
|
||||||
params.limit,
|
params.limit,
|
||||||
)?;
|
)?;
|
||||||
|
spinner.finish_and_clear();
|
||||||
|
|
||||||
Ok(TimelineResult {
|
Ok(TimelineResult {
|
||||||
query: params.query.clone(),
|
query: params.query.clone(),
|
||||||
|
search_mode: seed_result.search_mode,
|
||||||
events,
|
events,
|
||||||
total_events_before_limit: total_before_limit,
|
total_events_before_limit: total_before_limit,
|
||||||
seed_entities: seed_result.seed_entities,
|
seed_entities: seed_result.seed_entities,
|
||||||
@@ -98,19 +191,21 @@ pub fn print_timeline(result: &TimelineResult) {
|
|||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
style(format!(
|
Theme::bold().render(&format!(
|
||||||
"Timeline: \"{}\" ({} events across {} entities)",
|
"Timeline: \"{}\" ({} events across {} entities)",
|
||||||
result.query,
|
result.query,
|
||||||
result.events.len(),
|
result.events.len(),
|
||||||
entity_count,
|
entity_count,
|
||||||
))
|
))
|
||||||
.bold()
|
|
||||||
);
|
);
|
||||||
println!("{}", "─".repeat(60));
|
println!("{}", "\u{2500}".repeat(60));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if result.events.is_empty() {
|
if result.events.is_empty() {
|
||||||
println!(" {}", style("No events found for this query.").dim());
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::dim().render("No events found for this query.")
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,13 +215,18 @@ pub fn print_timeline(result: &TimelineResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("{}", "─".repeat(60));
|
println!("{}", "\u{2500}".repeat(60));
|
||||||
print_timeline_footer(result);
|
print_timeline_footer(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_timeline_event(event: &TimelineEvent) {
|
fn print_timeline_event(event: &TimelineEvent) {
|
||||||
let date = format_date(event.timestamp);
|
let date = render::format_date(event.timestamp);
|
||||||
let tag = format_event_tag(&event.event_type);
|
let tag = format_event_tag(&event.event_type);
|
||||||
|
let entity_icon = match event.entity_type.as_str() {
|
||||||
|
"issue" => Icons::issue_opened(),
|
||||||
|
"merge_request" => Icons::mr_opened(),
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
||||||
let actor = event
|
let actor = event
|
||||||
.actor
|
.actor
|
||||||
@@ -135,21 +235,41 @@ fn print_timeline_event(event: &TimelineEvent) {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
||||||
|
|
||||||
let summary = truncate_summary(&event.summary, 50);
|
let summary = render::truncate(&event.summary, 50);
|
||||||
let tag_padded = pad_str(&tag, 12, Alignment::Left, None);
|
println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||||
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
|
||||||
|
|
||||||
// Show snippet for evidence notes
|
// Show snippet for evidence notes
|
||||||
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
||||||
&& !snippet.is_empty()
|
&& !snippet.is_empty()
|
||||||
{
|
{
|
||||||
for line in wrap_snippet(snippet, 60) {
|
let mut lines = render::wrap_lines(snippet, 60);
|
||||||
|
lines.truncate(4);
|
||||||
|
for line in lines {
|
||||||
println!(
|
println!(
|
||||||
" \"{}\"",
|
" \"{}\"",
|
||||||
style(line).dim()
|
Theme::dim().render(&line)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show full discussion thread
|
||||||
|
if let TimelineEventType::DiscussionThread { notes, .. } = &event.event_type {
|
||||||
|
let bar = "\u{2500}".repeat(44);
|
||||||
|
println!(" \u{2500}\u{2500} Discussion {bar}");
|
||||||
|
for note in notes {
|
||||||
|
let note_date = render::format_date(note.created_at);
|
||||||
|
let author = note
|
||||||
|
.author
|
||||||
|
.as_deref()
|
||||||
|
.map(|a| format!("@{a}"))
|
||||||
|
.unwrap_or_else(|| "unknown".to_owned());
|
||||||
|
println!(" {} ({note_date}):", Theme::bold().render(&author));
|
||||||
|
for line in render::wrap_lines(¬e.body, 60) {
|
||||||
|
println!(" {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" {}", "\u{2500}".repeat(60));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_timeline_footer(result: &TimelineResult) {
|
fn print_timeline_footer(result: &TimelineResult) {
|
||||||
@@ -180,22 +300,33 @@ fn print_timeline_footer(result: &TimelineResult) {
|
|||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format event tag: pad plain text to TAG_WIDTH, then apply style.
|
||||||
|
const TAG_WIDTH: usize = 11;
|
||||||
|
|
||||||
fn format_event_tag(event_type: &TimelineEventType) -> String {
|
fn format_event_tag(event_type: &TimelineEventType) -> String {
|
||||||
match event_type {
|
let (label, style) = match event_type {
|
||||||
TimelineEventType::Created => style("CREATED").green().to_string(),
|
TimelineEventType::Created => ("CREATED", Theme::success()),
|
||||||
TimelineEventType::StateChanged { state } => match state.as_str() {
|
TimelineEventType::StateChanged { state } => match state.as_str() {
|
||||||
"closed" => style("CLOSED").red().to_string(),
|
"closed" => ("CLOSED", Theme::error()),
|
||||||
"reopened" => style("REOPENED").yellow().to_string(),
|
"reopened" => ("REOPENED", Theme::warning()),
|
||||||
_ => style(state.to_uppercase()).dim().to_string(),
|
_ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()),
|
||||||
},
|
},
|
||||||
TimelineEventType::LabelAdded { .. } => style("LABEL+").blue().to_string(),
|
TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()),
|
||||||
TimelineEventType::LabelRemoved { .. } => style("LABEL-").blue().to_string(),
|
TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()),
|
||||||
TimelineEventType::MilestoneSet { .. } => style("MILESTONE+").magenta().to_string(),
|
TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()),
|
||||||
TimelineEventType::MilestoneRemoved { .. } => style("MILESTONE-").magenta().to_string(),
|
TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()),
|
||||||
TimelineEventType::Merged => style("MERGED").cyan().to_string(),
|
TimelineEventType::Merged => ("MERGED", Theme::info()),
|
||||||
TimelineEventType::NoteEvidence { .. } => style("NOTE").dim().to_string(),
|
TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()),
|
||||||
TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(),
|
TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()),
|
||||||
}
|
TimelineEventType::CrossReferenced { .. } => ("REF", Theme::dim()),
|
||||||
|
};
|
||||||
|
style_padded(label, TAG_WIDTH, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pad text to width, then apply lipgloss style (so ANSI codes don't break alignment).
|
||||||
|
fn style_padded(text: &str, width: usize, style: lipgloss::Style) -> String {
|
||||||
|
let padded = format!("{:<width$}", text);
|
||||||
|
style.render(&padded)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||||
@@ -206,44 +337,6 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_date(ms: i64) -> String {
|
|
||||||
let iso = ms_to_iso(ms);
|
|
||||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_summary(s: &str, max: usize) -> String {
|
|
||||||
if s.chars().count() <= max {
|
|
||||||
s.to_owned()
|
|
||||||
} else {
|
|
||||||
let truncated: String = s.chars().take(max - 3).collect();
|
|
||||||
format!("{truncated}...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wrap_snippet(text: &str, width: usize) -> Vec<String> {
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut current = String::new();
|
|
||||||
|
|
||||||
for word in text.split_whitespace() {
|
|
||||||
if current.is_empty() {
|
|
||||||
current = word.to_string();
|
|
||||||
} else if current.len() + 1 + word.len() <= width {
|
|
||||||
current.push(' ');
|
|
||||||
current.push_str(word);
|
|
||||||
} else {
|
|
||||||
lines.push(current);
|
|
||||||
current = word.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !current.is_empty() {
|
|
||||||
lines.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap at 4 lines
|
|
||||||
lines.truncate(4);
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Robot JSON output ───────────────────────────────────────────────────────
|
// ─── Robot JSON output ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Render timeline as robot-mode JSON in {ok, data, meta} envelope.
|
/// Render timeline as robot-mode JSON in {ok, data, meta} envelope.
|
||||||
@@ -251,19 +344,20 @@ pub fn print_timeline_json_with_meta(
|
|||||||
result: &TimelineResult,
|
result: &TimelineResult,
|
||||||
total_events_before_limit: usize,
|
total_events_before_limit: usize,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
expand_mentions: bool,
|
include_mentions: bool,
|
||||||
fields: Option<&[String]>,
|
fields: Option<&[String]>,
|
||||||
) {
|
) {
|
||||||
let output = TimelineJsonEnvelope {
|
let output = TimelineJsonEnvelope {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: TimelineDataJson::from_result(result),
|
data: TimelineDataJson::from_result(result),
|
||||||
meta: TimelineMetaJson {
|
meta: TimelineMetaJson {
|
||||||
search_mode: "lexical".to_owned(),
|
search_mode: result.search_mode.clone(),
|
||||||
expansion_depth: depth,
|
expansion_depth: depth,
|
||||||
expand_mentions,
|
include_mentions,
|
||||||
total_entities: result.seed_entities.len() + result.expanded_entities.len(),
|
total_entities: result.seed_entities.len() + result.expanded_entities.len(),
|
||||||
total_events: total_events_before_limit,
|
total_events: total_events_before_limit,
|
||||||
evidence_notes_included: count_evidence_notes(&result.events),
|
evidence_notes_included: count_evidence_notes(&result.events),
|
||||||
|
discussion_threads_included: count_discussion_threads(&result.events),
|
||||||
unresolved_references: result.unresolved_references.len(),
|
unresolved_references: result.unresolved_references.len(),
|
||||||
showing: result.events.len(),
|
showing: result.events.len(),
|
||||||
},
|
},
|
||||||
@@ -461,6 +555,22 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va
|
|||||||
"discussion_id": discussion_id,
|
"discussion_id": discussion_id,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
TimelineEventType::DiscussionThread {
|
||||||
|
discussion_id,
|
||||||
|
notes,
|
||||||
|
} => (
|
||||||
|
"discussion_thread".to_owned(),
|
||||||
|
serde_json::json!({
|
||||||
|
"discussion_id": discussion_id,
|
||||||
|
"note_count": notes.len(),
|
||||||
|
"notes": notes.iter().map(|n| serde_json::json!({
|
||||||
|
"note_id": n.note_id,
|
||||||
|
"author": n.author,
|
||||||
|
"body": n.body,
|
||||||
|
"created_at": ms_to_iso(n.created_at),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
TimelineEventType::CrossReferenced { target } => (
|
TimelineEventType::CrossReferenced { target } => (
|
||||||
"cross_referenced".to_owned(),
|
"cross_referenced".to_owned(),
|
||||||
serde_json::json!({ "target": target }),
|
serde_json::json!({ "target": target }),
|
||||||
@@ -472,10 +582,11 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va
|
|||||||
struct TimelineMetaJson {
|
struct TimelineMetaJson {
|
||||||
search_mode: String,
|
search_mode: String,
|
||||||
expansion_depth: u32,
|
expansion_depth: u32,
|
||||||
expand_mentions: bool,
|
include_mentions: bool,
|
||||||
total_entities: usize,
|
total_entities: usize,
|
||||||
total_events: usize,
|
total_events: usize,
|
||||||
evidence_notes_included: usize,
|
evidence_notes_included: usize,
|
||||||
|
discussion_threads_included: usize,
|
||||||
unresolved_references: usize,
|
unresolved_references: usize,
|
||||||
showing: usize,
|
showing: usize,
|
||||||
}
|
}
|
||||||
@@ -486,3 +597,91 @@ fn count_evidence_notes(events: &[TimelineEvent]) -> usize {
|
|||||||
.filter(|e| matches!(e.event_type, TimelineEventType::NoteEvidence { .. }))
|
.filter(|e| matches!(e.event_type, TimelineEventType::NoteEvidence { .. }))
|
||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn count_discussion_threads(events: &[TimelineEvent]) -> usize {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e.event_type, TimelineEventType::DiscussionThread { .. }))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_issue_colon_number() {
|
||||||
|
let q = parse_timeline_query("issue:42");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_i_colon_number() {
|
||||||
|
let q = parse_timeline_query("i:42");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mr_colon_number() {
|
||||||
|
let q = parse_timeline_query("mr:99");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_m_colon_number() {
|
||||||
|
let q = parse_timeline_query("m:99");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_case_insensitive() {
|
||||||
|
let q = parse_timeline_query("ISSUE:42");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||||
|
);
|
||||||
|
|
||||||
|
let q = parse_timeline_query("MR:99");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99)
|
||||||
|
);
|
||||||
|
|
||||||
|
let q = parse_timeline_query("Issue:7");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 7)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_search_fallback() {
|
||||||
|
let q = parse_timeline_query("switch health");
|
||||||
|
assert!(matches!(q, TimelineQuery::Search(ref s) if s == "switch health"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_non_numeric_falls_back_to_search() {
|
||||||
|
let q = parse_timeline_query("issue:abc");
|
||||||
|
assert!(matches!(q, TimelineQuery::Search(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_unknown_prefix_falls_back_to_search() {
|
||||||
|
let q = parse_timeline_query("foo:42");
|
||||||
|
assert!(matches!(q, TimelineQuery::Search(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_whitespace_trimmed() {
|
||||||
|
let q = parse_timeline_query(" issue:42 ");
|
||||||
|
assert!(
|
||||||
|
matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
242
src/cli/commands/trace.rs
Normal file
242
src/cli/commands/trace.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use crate::cli::render::{Icons, Theme};
|
||||||
|
use crate::core::trace::{TraceChain, TraceResult};
|
||||||
|
|
||||||
|
/// Parse a path with optional `:line` suffix.
|
||||||
|
///
|
||||||
|
/// Handles Windows drive letters (e.g. `C:/foo.rs`) by checking that the
|
||||||
|
/// prefix before the colon is not a single ASCII letter.
|
||||||
|
pub fn parse_trace_path(input: &str) -> (String, Option<u32>) {
|
||||||
|
if let Some((path, suffix)) = input.rsplit_once(':')
|
||||||
|
&& !path.is_empty()
|
||||||
|
&& let Ok(line) = suffix.parse::<u32>()
|
||||||
|
// Reject Windows drive letters: single ASCII letter before colon
|
||||||
|
&& (path.len() > 1 || !path.chars().next().unwrap_or(' ').is_ascii_alphabetic())
|
||||||
|
{
|
||||||
|
return (path.to_string(), Some(line));
|
||||||
|
}
|
||||||
|
(input.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Human output ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn print_trace(result: &TraceResult) {
|
||||||
|
let chain_info = if result.total_chains == 1 {
|
||||||
|
"1 chain".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} chains", result.total_chains)
|
||||||
|
};
|
||||||
|
|
||||||
|
let paths_info = if result.resolved_paths.len() > 1 {
|
||||||
|
format!(", {} paths", result.resolved_paths.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
Theme::bold().render(&format!(
|
||||||
|
"Trace: {} ({}{})",
|
||||||
|
result.path, chain_info, paths_info
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rename chain
|
||||||
|
if result.renames_followed && result.resolved_paths.len() > 1 {
|
||||||
|
let chain_str: Vec<&str> = result.resolved_paths.iter().map(String::as_str).collect();
|
||||||
|
println!(
|
||||||
|
" Rename chain: {}",
|
||||||
|
Theme::dim().render(&chain_str.join(" -> "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.trace_chains.is_empty() {
|
||||||
|
println!(
|
||||||
|
"\n {} {}",
|
||||||
|
Icons::info(),
|
||||||
|
Theme::dim().render("No trace chains found for this file.")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::dim()
|
||||||
|
.render("Hint: Run 'lore sync' to fetch MR file changes and cross-references.")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for chain in &result.trace_chains {
|
||||||
|
print_chain(chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_chain(chain: &TraceChain) {
|
||||||
|
let (icon, state_style) = match chain.mr_state.as_str() {
|
||||||
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||||
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||||
|
"closed" => (Icons::mr_closed(), Theme::warning()),
|
||||||
|
_ => (Icons::mr_opened(), Theme::dim()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let date = chain
|
||||||
|
.merged_at_iso
|
||||||
|
.as_deref()
|
||||||
|
.or(Some(chain.updated_at_iso.as_str()))
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('T')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} @{} {} {}",
|
||||||
|
icon,
|
||||||
|
Theme::accent().render(&format!("!{}", chain.mr_iid)),
|
||||||
|
chain.mr_title,
|
||||||
|
state_style.render(&chain.mr_state),
|
||||||
|
chain.mr_author,
|
||||||
|
date,
|
||||||
|
Theme::dim().render(&chain.change_type),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Linked issues
|
||||||
|
for issue in &chain.issues {
|
||||||
|
let ref_icon = match issue.reference_type.as_str() {
|
||||||
|
"closes" => Icons::issue_closed(),
|
||||||
|
_ => Icons::issue_opened(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} #{} {} {} [{}]",
|
||||||
|
ref_icon,
|
||||||
|
issue.iid,
|
||||||
|
issue.title,
|
||||||
|
Theme::dim().render(&issue.state),
|
||||||
|
Theme::dim().render(&issue.reference_type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discussions
|
||||||
|
for disc in &chain.discussions {
|
||||||
|
let date = disc.created_at_iso.split('T').next().unwrap_or("");
|
||||||
|
println!(
|
||||||
|
" {} @{} ({}) [{}]: {}",
|
||||||
|
Icons::note(),
|
||||||
|
disc.author_username,
|
||||||
|
date,
|
||||||
|
Theme::dim().render(&disc.path),
|
||||||
|
disc.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Robot (JSON) output ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Maximum body length in robot JSON output (token efficiency).
|
||||||
|
const ROBOT_BODY_SNIPPET_LEN: usize = 500;
|
||||||
|
|
||||||
|
fn truncate_body(body: &str, max: usize) -> String {
|
||||||
|
if body.len() <= max {
|
||||||
|
return body.to_string();
|
||||||
|
}
|
||||||
|
let boundary = body.floor_char_boundary(max);
|
||||||
|
format!("{}...", &body[..boundary])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: Option<u32>) {
|
||||||
|
// Truncate discussion bodies for token efficiency in robot mode
|
||||||
|
let chains: Vec<serde_json::Value> = result
|
||||||
|
.trace_chains
|
||||||
|
.iter()
|
||||||
|
.map(|chain| {
|
||||||
|
let discussions: Vec<serde_json::Value> = chain
|
||||||
|
.discussions
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
serde_json::json!({
|
||||||
|
"discussion_id": d.discussion_id,
|
||||||
|
"mr_iid": d.mr_iid,
|
||||||
|
"author_username": d.author_username,
|
||||||
|
"body_snippet": truncate_body(&d.body, ROBOT_BODY_SNIPPET_LEN),
|
||||||
|
"path": d.path,
|
||||||
|
"created_at_iso": d.created_at_iso,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"mr_iid": chain.mr_iid,
|
||||||
|
"mr_title": chain.mr_title,
|
||||||
|
"mr_state": chain.mr_state,
|
||||||
|
"mr_author": chain.mr_author,
|
||||||
|
"change_type": chain.change_type,
|
||||||
|
"merged_at_iso": chain.merged_at_iso,
|
||||||
|
"updated_at_iso": chain.updated_at_iso,
|
||||||
|
"web_url": chain.web_url,
|
||||||
|
"issues": chain.issues,
|
||||||
|
"discussions": discussions,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output = serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"path": result.path,
|
||||||
|
"resolved_paths": result.resolved_paths,
|
||||||
|
"trace_chains": chains,
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"tier": "api_only",
|
||||||
|
"line_requested": line_requested,
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
"total_chains": result.total_chains,
|
||||||
|
"renames_followed": result.renames_followed,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_trace_path_simple() {
|
||||||
|
let (path, line) = parse_trace_path("src/foo.rs");
|
||||||
|
assert_eq!(path, "src/foo.rs");
|
||||||
|
assert_eq!(line, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_trace_path_with_line() {
|
||||||
|
let (path, line) = parse_trace_path("src/foo.rs:42");
|
||||||
|
assert_eq!(path, "src/foo.rs");
|
||||||
|
assert_eq!(line, Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_trace_path_windows() {
|
||||||
|
let (path, line) = parse_trace_path("C:/foo.rs");
|
||||||
|
assert_eq!(path, "C:/foo.rs");
|
||||||
|
assert_eq!(line, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_trace_path_directory() {
|
||||||
|
let (path, line) = parse_trace_path("src/auth/");
|
||||||
|
assert_eq!(path, "src/auth/");
|
||||||
|
assert_eq!(line, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_trace_path_with_line_zero() {
|
||||||
|
let (path, line) = parse_trace_path("file.rs:0");
|
||||||
|
assert_eq!(path, "file.rs");
|
||||||
|
assert_eq!(line, Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
3267
src/cli/commands/who_tests.rs
Normal file
3267
src/cli/commands/who_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
279
src/cli/mod.rs
279
src/cli/mod.rs
@@ -1,6 +1,7 @@
|
|||||||
pub mod autocorrect;
|
pub mod autocorrect;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
|
pub mod render;
|
||||||
pub mod robot;
|
pub mod robot;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
@@ -10,6 +11,7 @@ use std::io::IsTerminal;
|
|||||||
#[command(name = "lore")]
|
#[command(name = "lore")]
|
||||||
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
|
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
|
||||||
#[command(subcommand_required = false)]
|
#[command(subcommand_required = false)]
|
||||||
|
#[command(infer_subcommands = true)]
|
||||||
#[command(after_long_help = "\x1b[1mEnvironment:\x1b[0m
|
#[command(after_long_help = "\x1b[1mEnvironment:\x1b[0m
|
||||||
GITLAB_TOKEN GitLab personal access token (or name set in config)
|
GITLAB_TOKEN GitLab personal access token (or name set in config)
|
||||||
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
|
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
|
||||||
@@ -42,6 +44,10 @@ pub struct Cli {
|
|||||||
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
|
||||||
|
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
|
||||||
|
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
|
||||||
|
pub icons: Option<String>,
|
||||||
|
|
||||||
/// Suppress non-essential output
|
/// Suppress non-essential output
|
||||||
#[arg(
|
#[arg(
|
||||||
short = 'q',
|
short = 'q',
|
||||||
@@ -107,11 +113,21 @@ impl Cli {
|
|||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
pub enum Commands {
|
pub enum Commands {
|
||||||
/// List or show issues
|
/// List or show issues
|
||||||
|
#[command(visible_alias = "issue")]
|
||||||
Issues(IssuesArgs),
|
Issues(IssuesArgs),
|
||||||
|
|
||||||
/// List or show merge requests
|
/// List or show merge requests
|
||||||
|
#[command(
|
||||||
|
visible_alias = "mr",
|
||||||
|
alias = "merge-requests",
|
||||||
|
alias = "merge-request"
|
||||||
|
)]
|
||||||
Mrs(MrsArgs),
|
Mrs(MrsArgs),
|
||||||
|
|
||||||
|
/// List notes from discussions
|
||||||
|
#[command(visible_alias = "note")]
|
||||||
|
Notes(NotesArgs),
|
||||||
|
|
||||||
/// Ingest data from GitLab
|
/// Ingest data from GitLab
|
||||||
Ingest(IngestArgs),
|
Ingest(IngestArgs),
|
||||||
|
|
||||||
@@ -119,6 +135,7 @@ pub enum Commands {
|
|||||||
Count(CountArgs),
|
Count(CountArgs),
|
||||||
|
|
||||||
/// Show sync state
|
/// Show sync state
|
||||||
|
#[command(visible_alias = "st")]
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Verify GitLab authentication
|
/// Verify GitLab authentication
|
||||||
@@ -151,6 +168,10 @@ pub enum Commands {
|
|||||||
/// Comma-separated project paths (required in robot mode)
|
/// Comma-separated project paths (required in robot mode)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
projects: Option<String>,
|
projects: Option<String>,
|
||||||
|
|
||||||
|
/// Default project path (used when -p is omitted)
|
||||||
|
#[arg(long)]
|
||||||
|
default_project: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
@@ -163,9 +184,11 @@ pub enum Commands {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Search indexed documents
|
/// Search indexed documents
|
||||||
|
#[command(visible_alias = "find", alias = "query")]
|
||||||
Search(SearchArgs),
|
Search(SearchArgs),
|
||||||
|
|
||||||
/// Show document and index statistics
|
/// Show document and index statistics
|
||||||
|
#[command(visible_alias = "stat")]
|
||||||
Stats(StatsArgs),
|
Stats(StatsArgs),
|
||||||
|
|
||||||
/// Generate searchable documents from ingested data
|
/// Generate searchable documents from ingested data
|
||||||
@@ -211,6 +234,31 @@ pub enum Commands {
|
|||||||
/// People intelligence: experts, workload, active discussions, overlap
|
/// People intelligence: experts, workload, active discussions, overlap
|
||||||
Who(WhoArgs),
|
Who(WhoArgs),
|
||||||
|
|
||||||
|
/// Show MRs that touched a file, with linked discussions
|
||||||
|
#[command(name = "file-history")]
|
||||||
|
FileHistory(FileHistoryArgs),
|
||||||
|
|
||||||
|
/// Trace why code was introduced: file -> MR -> issue -> discussion
|
||||||
|
Trace(TraceArgs),
|
||||||
|
|
||||||
|
/// Detect discussion divergence from original intent
|
||||||
|
Drift {
|
||||||
|
/// Entity type (currently only "issues" supported)
|
||||||
|
#[arg(value_parser = ["issues"])]
|
||||||
|
entity_type: String,
|
||||||
|
|
||||||
|
/// Entity IID
|
||||||
|
iid: i64,
|
||||||
|
|
||||||
|
/// Similarity threshold for drift detection (0.0-1.0)
|
||||||
|
#[arg(long, default_value = "0.4")]
|
||||||
|
threshold: f32,
|
||||||
|
|
||||||
|
/// Scope to project (fuzzy match)
|
||||||
|
#[arg(short, long)]
|
||||||
|
project: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
List {
|
List {
|
||||||
#[arg(value_parser = ["issues", "mrs"])]
|
#[arg(value_parser = ["issues", "mrs"])]
|
||||||
@@ -467,6 +515,113 @@ pub struct MrsArgs {
|
|||||||
pub no_open: bool,
|
pub no_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore notes # List 50 most recent notes
|
||||||
|
lore notes --author alice --since 7d # Notes by alice in last 7 days
|
||||||
|
lore notes --for-issue 42 -p group/repo # Notes on issue #42
|
||||||
|
lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/")]
|
||||||
|
pub struct NotesArgs {
|
||||||
|
/// Maximum results
|
||||||
|
#[arg(
|
||||||
|
short = 'n',
|
||||||
|
long = "limit",
|
||||||
|
default_value = "50",
|
||||||
|
help_heading = "Output"
|
||||||
|
)]
|
||||||
|
pub limit: usize,
|
||||||
|
|
||||||
|
/// Select output fields (comma-separated, or 'minimal' preset: id,author_username,body,created_at_iso)
|
||||||
|
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||||
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Output format (table, json, jsonl, csv)
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "table",
|
||||||
|
value_parser = ["table", "json", "jsonl", "csv"],
|
||||||
|
help_heading = "Output"
|
||||||
|
)]
|
||||||
|
pub format: String,
|
||||||
|
|
||||||
|
/// Filter by author username
|
||||||
|
#[arg(short = 'a', long, help_heading = "Filters")]
|
||||||
|
pub author: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by note type (DiffNote, DiscussionNote)
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub note_type: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by body text (substring match)
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub contains: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by internal note ID
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub note_id: Option<i64>,
|
||||||
|
|
||||||
|
/// Filter by GitLab note ID
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub gitlab_note_id: Option<i64>,
|
||||||
|
|
||||||
|
/// Filter by discussion ID
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub discussion_id: Option<String>,
|
||||||
|
|
||||||
|
/// Include system notes (excluded by default)
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub include_system: bool,
|
||||||
|
|
||||||
|
/// Filter to notes on a specific issue IID (requires --project or default_project)
|
||||||
|
#[arg(long, conflicts_with = "for_mr", help_heading = "Filters")]
|
||||||
|
pub for_issue: Option<i64>,
|
||||||
|
|
||||||
|
/// Filter to notes on a specific MR IID (requires --project or default_project)
|
||||||
|
#[arg(long, conflicts_with = "for_issue", help_heading = "Filters")]
|
||||||
|
pub for_mr: Option<i64>,
|
||||||
|
|
||||||
|
/// Filter by project path
|
||||||
|
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||||
|
pub project: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub since: Option<String>,
|
||||||
|
|
||||||
|
/// Filter until date (YYYY-MM-DD, inclusive end-of-day)
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub until: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by file path (exact match or prefix with trailing /)
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by resolution status (any, unresolved, resolved)
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_parser = ["any", "unresolved", "resolved"],
|
||||||
|
help_heading = "Filters"
|
||||||
|
)]
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
|
||||||
|
/// Sort field (created, updated)
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_parser = ["created", "updated"],
|
||||||
|
default_value = "created",
|
||||||
|
help_heading = "Sorting"
|
||||||
|
)]
|
||||||
|
pub sort: String,
|
||||||
|
|
||||||
|
/// Sort ascending (default: descending)
|
||||||
|
#[arg(long, help_heading = "Sorting")]
|
||||||
|
pub asc: bool,
|
||||||
|
|
||||||
|
/// Open first matching item in browser
|
||||||
|
#[arg(long, help_heading = "Actions")]
|
||||||
|
pub open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
pub struct IngestArgs {
|
pub struct IngestArgs {
|
||||||
/// Entity to ingest (issues, mrs). Omit to ingest everything
|
/// Entity to ingest (issues, mrs). Omit to ingest everything
|
||||||
@@ -534,8 +689,8 @@ pub struct SearchArgs {
|
|||||||
#[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Mode")]
|
#[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Mode")]
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
|
|
||||||
/// Filter by source type (issue, mr, discussion)
|
/// Filter by source type (issue, mr, discussion, note)
|
||||||
#[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")]
|
#[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion", "note"], help_heading = "Filters")]
|
||||||
pub source_type: Option<String>,
|
pub source_type: Option<String>,
|
||||||
|
|
||||||
/// Filter by author username
|
/// Filter by author username
|
||||||
@@ -602,6 +757,7 @@ pub struct GenerateDocsArgs {
|
|||||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
lore sync # Full pipeline: ingest + docs + embed
|
lore sync # Full pipeline: ingest + docs + embed
|
||||||
lore sync --no-embed # Skip embedding step
|
lore sync --no-embed # Skip embedding step
|
||||||
|
lore sync --no-status # Skip work-item status enrichment
|
||||||
lore sync --full --force # Full re-sync, override stale lock
|
lore sync --full --force # Full re-sync, override stale lock
|
||||||
lore sync --dry-run # Preview what would change")]
|
lore sync --dry-run # Preview what would change")]
|
||||||
pub struct SyncArgs {
|
pub struct SyncArgs {
|
||||||
@@ -635,12 +791,20 @@ pub struct SyncArgs {
|
|||||||
#[arg(long = "no-file-changes")]
|
#[arg(long = "no-file-changes")]
|
||||||
pub no_file_changes: bool,
|
pub no_file_changes: bool,
|
||||||
|
|
||||||
|
/// Skip work-item status enrichment via GraphQL (overrides config)
|
||||||
|
#[arg(long = "no-status")]
|
||||||
|
pub no_status: bool,
|
||||||
|
|
||||||
/// Preview what would be synced without making changes
|
/// Preview what would be synced without making changes
|
||||||
#[arg(long, overrides_with = "no_dry_run")]
|
#[arg(long, overrides_with = "no_dry_run")]
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
|
|
||||||
#[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")]
|
#[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")]
|
||||||
pub no_dry_run: bool,
|
pub no_dry_run: bool,
|
||||||
|
|
||||||
|
/// Show detailed timing breakdown for sync stages
|
||||||
|
#[arg(short = 't', long = "timings")]
|
||||||
|
pub timings: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -662,11 +826,15 @@ pub struct EmbedArgs {
|
|||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
lore timeline 'deployment' # Events related to deployments
|
lore timeline 'deployment' # Search-based seeding
|
||||||
|
lore timeline issue:42 # Direct: issue #42 and related entities
|
||||||
|
lore timeline i:42 # Shorthand for issue:42
|
||||||
|
lore timeline mr:99 # Direct: MR !99 and related entities
|
||||||
lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time
|
lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time
|
||||||
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
|
lore timeline 'migration' --depth 2 # Deep cross-reference expansion
|
||||||
|
lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")]
|
||||||
pub struct TimelineArgs {
|
pub struct TimelineArgs {
|
||||||
/// Search query (keywords to find in issues, MRs, and discussions)
|
/// Search text or entity reference (issue:N, i:N, mr:N, m:N)
|
||||||
pub query: String,
|
pub query: String,
|
||||||
|
|
||||||
/// Scope to a specific project (fuzzy match)
|
/// Scope to a specific project (fuzzy match)
|
||||||
@@ -681,9 +849,9 @@ pub struct TimelineArgs {
|
|||||||
#[arg(long, default_value = "1", help_heading = "Expansion")]
|
#[arg(long, default_value = "1", help_heading = "Expansion")]
|
||||||
pub depth: u32,
|
pub depth: u32,
|
||||||
|
|
||||||
/// Also follow 'mentioned' edges during expansion (high fan-out)
|
/// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related')
|
||||||
#[arg(long = "expand-mentions", help_heading = "Expansion")]
|
#[arg(long = "no-mentions", help_heading = "Expansion")]
|
||||||
pub expand_mentions: bool,
|
pub no_mentions: bool,
|
||||||
|
|
||||||
/// Maximum number of events to display
|
/// Maximum number of events to display
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -773,11 +941,104 @@ pub struct WhoArgs {
|
|||||||
pub fields: Option<Vec<String>>,
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Show per-MR detail breakdown (expert mode only)
|
/// Show per-MR detail breakdown (expert mode only)
|
||||||
#[arg(long, help_heading = "Output", overrides_with = "no_detail")]
|
#[arg(
|
||||||
|
long,
|
||||||
|
help_heading = "Output",
|
||||||
|
overrides_with = "no_detail",
|
||||||
|
conflicts_with = "explain_score"
|
||||||
|
)]
|
||||||
pub detail: bool,
|
pub detail: bool,
|
||||||
|
|
||||||
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
||||||
pub no_detail: bool,
|
pub no_detail: bool,
|
||||||
|
|
||||||
|
/// Score as if "now" is this date (ISO 8601 or duration like 30d). Expert mode only.
|
||||||
|
#[arg(long = "as-of", help_heading = "Scoring")]
|
||||||
|
pub as_of: Option<String>,
|
||||||
|
|
||||||
|
/// Show per-component score breakdown in output. Expert mode only.
|
||||||
|
#[arg(long = "explain-score", help_heading = "Scoring")]
|
||||||
|
pub explain_score: bool,
|
||||||
|
|
||||||
|
/// Include bot users in results (normally excluded via scoring.excluded_usernames).
|
||||||
|
#[arg(long = "include-bots", help_heading = "Scoring")]
|
||||||
|
pub include_bots: bool,
|
||||||
|
|
||||||
|
/// Remove the default time window (query all history). Conflicts with --since.
|
||||||
|
#[arg(
|
||||||
|
long = "all-history",
|
||||||
|
help_heading = "Filters",
|
||||||
|
conflicts_with = "since"
|
||||||
|
)]
|
||||||
|
pub all_history: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore file-history src/main.rs # MRs that touched this file
|
||||||
|
lore file-history src/auth/ -p group/repo # Scoped to project
|
||||||
|
lore file-history src/foo.rs --discussions # Include DiffNote snippets
|
||||||
|
lore file-history src/bar.rs --no-follow-renames # Skip rename chain")]
|
||||||
|
pub struct FileHistoryArgs {
|
||||||
|
/// File path to trace history for
|
||||||
|
pub path: String,
|
||||||
|
|
||||||
|
/// Scope to a specific project (fuzzy match)
|
||||||
|
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||||
|
pub project: Option<String>,
|
||||||
|
|
||||||
|
/// Include discussion snippets from DiffNotes on this file
|
||||||
|
#[arg(long, help_heading = "Output")]
|
||||||
|
pub discussions: bool,
|
||||||
|
|
||||||
|
/// Disable rename chain resolution
|
||||||
|
#[arg(long = "no-follow-renames", help_heading = "Filters")]
|
||||||
|
pub no_follow_renames: bool,
|
||||||
|
|
||||||
|
/// Only show merged MRs
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub merged: bool,
|
||||||
|
|
||||||
|
/// Maximum results
|
||||||
|
#[arg(
|
||||||
|
short = 'n',
|
||||||
|
long = "limit",
|
||||||
|
default_value = "50",
|
||||||
|
help_heading = "Output"
|
||||||
|
)]
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore trace src/main.rs # Why was this file changed?
|
||||||
|
lore trace src/auth/ -p group/repo # Scoped to project
|
||||||
|
lore trace src/foo.rs --discussions # Include DiffNote context
|
||||||
|
lore trace src/bar.rs:42 # Line hint (Tier 2 warning)")]
|
||||||
|
pub struct TraceArgs {
|
||||||
|
/// File path to trace (supports :line suffix for future Tier 2)
|
||||||
|
pub path: String,
|
||||||
|
|
||||||
|
/// Scope to a specific project (fuzzy match)
|
||||||
|
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||||
|
pub project: Option<String>,
|
||||||
|
|
||||||
|
/// Include DiffNote discussion snippets
|
||||||
|
#[arg(long, help_heading = "Output")]
|
||||||
|
pub discussions: bool,
|
||||||
|
|
||||||
|
/// Disable rename chain resolution
|
||||||
|
#[arg(long = "no-follow-renames", help_heading = "Filters")]
|
||||||
|
pub no_follow_renames: bool,
|
||||||
|
|
||||||
|
/// Maximum trace chains to display
|
||||||
|
#[arg(
|
||||||
|
short = 'n',
|
||||||
|
long = "limit",
|
||||||
|
default_value = "20",
|
||||||
|
help_heading = "Output"
|
||||||
|
)]
|
||||||
|
pub limit: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|||||||
@@ -1,14 +1,91 @@
|
|||||||
use indicatif::MultiProgress;
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
|
use crate::cli::render::{Icons, Theme};
|
||||||
|
|
||||||
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
||||||
|
|
||||||
pub fn multi() -> &'static MultiProgress {
|
pub fn multi() -> &'static MultiProgress {
|
||||||
&MULTI
|
&MULTI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage spinner with icon prefix and elapsed time on the right.
|
||||||
|
///
|
||||||
|
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
|
||||||
|
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
|
||||||
|
if robot_mode {
|
||||||
|
return ProgressBar::hidden();
|
||||||
|
}
|
||||||
|
let pb = multi().add(ProgressBar::new_spinner());
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_spinner()
|
||||||
|
.template(" {spinner:.cyan} {prefix} {wide_msg}")
|
||||||
|
.expect("valid template"),
|
||||||
|
);
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(60));
|
||||||
|
pb.set_prefix(format!("{icon} {label}"));
|
||||||
|
pb.set_message(msg.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested progress bar with count, throughput, and ETA.
|
||||||
|
///
|
||||||
|
/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}`
|
||||||
|
pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar {
|
||||||
|
if robot_mode {
|
||||||
|
return ProgressBar::hidden();
|
||||||
|
}
|
||||||
|
let pb = multi().add(ProgressBar::new(len));
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template(
|
||||||
|
" {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||||
|
)
|
||||||
|
.expect("valid template")
|
||||||
|
.progress_chars(Icons::progress_chars()),
|
||||||
|
);
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(60));
|
||||||
|
pb.set_message(msg.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed.
|
||||||
|
///
|
||||||
|
/// Output: ` ✓ Label summary elapsed`
|
||||||
|
pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) {
|
||||||
|
let line = format_stage_line(icon, label, summary, elapsed);
|
||||||
|
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
||||||
|
pb.finish_with_message(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a static stage line showing icon, label, summary, and elapsed.
|
||||||
|
///
|
||||||
|
/// Output: ` ✓ Label summary elapsed`
|
||||||
|
pub fn format_stage_line(icon: &str, label: &str, summary: &str, elapsed: Duration) -> String {
|
||||||
|
let elapsed_str = format_elapsed(elapsed);
|
||||||
|
let styled_label = Theme::info().bold().render(&format!("{label:<12}"));
|
||||||
|
let styled_elapsed = Theme::timing().render(&format!("{elapsed_str:>8}"));
|
||||||
|
format!(" {icon} {styled_label}{summary:>40} {styled_elapsed}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s").
|
||||||
|
fn format_elapsed(d: Duration) -> String {
|
||||||
|
let ms = d.as_millis();
|
||||||
|
if ms < 1000 {
|
||||||
|
format!("{ms}ms")
|
||||||
|
} else if ms < 60_000 {
|
||||||
|
format!("{:.1}s", ms as f64 / 1000.0)
|
||||||
|
} else {
|
||||||
|
let secs = d.as_secs();
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{m}m {s}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SuspendingWriter;
|
pub struct SuspendingWriter;
|
||||||
|
|
||||||
@@ -50,7 +127,6 @@ impl<'a> MakeWriter<'a> for SuspendingWriter {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use indicatif::ProgressBar;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_returns_same_instance() {
|
fn multi_returns_same_instance() {
|
||||||
@@ -88,4 +164,61 @@ mod tests {
|
|||||||
let w = MakeWriter::make_writer(&writer);
|
let w = MakeWriter::make_writer(&writer);
|
||||||
drop(w);
|
drop(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Progress API tests ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_spinner_v2_robot_mode_returns_hidden() {
|
||||||
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
|
||||||
|
assert!(pb.is_hidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_spinner_v2_human_mode_sets_properties() {
|
||||||
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
|
||||||
|
assert!(pb.prefix().contains("Issues"));
|
||||||
|
assert_eq!(pb.message(), "fetching...");
|
||||||
|
pb.finish_and_clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_progress_robot_mode_returns_hidden() {
|
||||||
|
let pb = nested_progress("Embedding...", 100, true);
|
||||||
|
assert!(pb.is_hidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_progress_human_mode_sets_length() {
|
||||||
|
let pb = nested_progress("Embedding...", 100, false);
|
||||||
|
assert_eq!(pb.length(), Some(100));
|
||||||
|
assert_eq!(pb.message(), "Embedding...");
|
||||||
|
pb.finish_and_clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_elapsed_sub_second() {
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_elapsed_seconds() {
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s");
|
||||||
|
assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_elapsed_minutes() {
|
||||||
|
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s");
|
||||||
|
assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_stage_line_includes_label_summary_and_elapsed() {
|
||||||
|
let line = format_stage_line("✔", "Issues", "10 issues", Duration::from_millis(4200));
|
||||||
|
assert!(line.contains("✔"));
|
||||||
|
assert!(line.contains("Issues"));
|
||||||
|
assert!(line.contains("10 issues"));
|
||||||
|
assert!(line.contains("4.2s"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1392
src/cli/render.rs
Normal file
1392
src/cli/render.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,10 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|s| (*s).to_string())
|
.map(|s| (*s).to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
"notes" => ["id", "author_username", "body", "created_at_iso"]
|
||||||
|
.iter()
|
||||||
|
.map(|s| (*s).to_string())
|
||||||
|
.collect(),
|
||||||
_ => fields.to_vec(),
|
_ => fields.to_vec(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -77,7 +81,30 @@ pub fn strip_schemas(commands: &mut serde_json::Value) {
|
|||||||
for (_cmd_name, cmd) in map.iter_mut() {
|
for (_cmd_name, cmd) in map.iter_mut() {
|
||||||
if let Some(obj) = cmd.as_object_mut() {
|
if let Some(obj) = cmd.as_object_mut() {
|
||||||
obj.remove("response_schema");
|
obj.remove("response_schema");
|
||||||
|
obj.remove("example_output");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_fields_preset_notes() {
|
||||||
|
let fields = vec!["minimal".to_string()];
|
||||||
|
let expanded = expand_fields_preset(&fields, "notes");
|
||||||
|
assert_eq!(
|
||||||
|
expanded,
|
||||||
|
["id", "author_username", "body", "created_at_iso"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_fields_preset_passthrough() {
|
||||||
|
let fields = vec!["id".to_string(), "body".to_string()];
|
||||||
|
let expanded = expand_fields_preset(&fields, "notes");
|
||||||
|
assert_eq!(expanded, ["id", "body"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,38 @@ pub struct ScoringConfig {
|
|||||||
/// Bonus points per individual inline review comment (DiffNote).
|
/// Bonus points per individual inline review comment (DiffNote).
|
||||||
#[serde(rename = "noteBonus")]
|
#[serde(rename = "noteBonus")]
|
||||||
pub note_bonus: i64,
|
pub note_bonus: i64,
|
||||||
|
|
||||||
|
/// Points per MR where the user was assigned as a reviewer.
|
||||||
|
#[serde(rename = "reviewerAssignmentWeight")]
|
||||||
|
pub reviewer_assignment_weight: i64,
|
||||||
|
|
||||||
|
/// Half-life in days for author contribution decay.
|
||||||
|
#[serde(rename = "authorHalfLifeDays")]
|
||||||
|
pub author_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Half-life in days for reviewer contribution decay.
|
||||||
|
#[serde(rename = "reviewerHalfLifeDays")]
|
||||||
|
pub reviewer_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Half-life in days for reviewer assignment decay.
|
||||||
|
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
|
||||||
|
pub reviewer_assignment_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Half-life in days for note/comment contribution decay.
|
||||||
|
#[serde(rename = "noteHalfLifeDays")]
|
||||||
|
pub note_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Multiplier applied to scores from closed (not merged) MRs.
|
||||||
|
#[serde(rename = "closedMrMultiplier")]
|
||||||
|
pub closed_mr_multiplier: f64,
|
||||||
|
|
||||||
|
/// Minimum character count for a review note to earn note_bonus.
|
||||||
|
#[serde(rename = "reviewerMinNoteChars")]
|
||||||
|
pub reviewer_min_note_chars: u32,
|
||||||
|
|
||||||
|
/// Usernames excluded from expert/scoring results.
|
||||||
|
#[serde(rename = "excludedUsernames")]
|
||||||
|
pub excluded_usernames: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScoringConfig {
|
impl Default for ScoringConfig {
|
||||||
@@ -172,6 +204,14 @@ impl Default for ScoringConfig {
|
|||||||
author_weight: 25,
|
author_weight: 25,
|
||||||
reviewer_weight: 10,
|
reviewer_weight: 10,
|
||||||
note_bonus: 1,
|
note_bonus: 1,
|
||||||
|
reviewer_assignment_weight: 3,
|
||||||
|
author_half_life_days: 180,
|
||||||
|
reviewer_half_life_days: 90,
|
||||||
|
reviewer_assignment_half_life_days: 45,
|
||||||
|
note_half_life_days: 45,
|
||||||
|
closed_mr_multiplier: 0.5,
|
||||||
|
reviewer_min_note_chars: 20,
|
||||||
|
excluded_usernames: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +221,9 @@ pub struct Config {
|
|||||||
pub gitlab: GitLabConfig,
|
pub gitlab: GitLabConfig,
|
||||||
pub projects: Vec<ProjectConfig>,
|
pub projects: Vec<ProjectConfig>,
|
||||||
|
|
||||||
|
#[serde(rename = "defaultProject")]
|
||||||
|
pub default_project: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync: SyncConfig,
|
pub sync: SyncConfig,
|
||||||
|
|
||||||
@@ -240,10 +283,32 @@ impl Config {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref dp) = config.default_project {
|
||||||
|
let matched = config.projects.iter().any(|p| {
|
||||||
|
p.path.eq_ignore_ascii_case(dp)
|
||||||
|
|| p.path
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.ends_with(&format!("/{}", dp.to_ascii_lowercase()))
|
||||||
|
});
|
||||||
|
if !matched {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: format!(
|
||||||
|
"defaultProject '{}' does not match any configured project path",
|
||||||
|
dp
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
validate_scoring(&config.scoring)?;
|
validate_scoring(&config.scoring)?;
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the effective project filter: CLI flag wins, then config default.
|
||||||
|
pub fn effective_project<'a>(&'a self, cli_project: Option<&'a str>) -> Option<&'a str> {
|
||||||
|
cli_project.or(self.default_project.as_deref())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
||||||
@@ -262,6 +327,55 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
|||||||
details: "scoring.noteBonus must be >= 0".to_string(),
|
details: "scoring.noteBonus must be >= 0".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if scoring.reviewer_assignment_weight < 0 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.author_half_life_days == 0 || scoring.author_half_life_days > 3650 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.authorHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_half_life_days == 0 || scoring.reviewer_half_life_days > 3650 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_assignment_half_life_days == 0
|
||||||
|
|| scoring.reviewer_assignment_half_life_days > 3650
|
||||||
|
{
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerAssignmentHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.note_half_life_days == 0 || scoring.note_half_life_days > 3650 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.noteHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !scoring.closed_mr_multiplier.is_finite()
|
||||||
|
|| scoring.closed_mr_multiplier <= 0.0
|
||||||
|
|| scoring.closed_mr_multiplier > 1.0
|
||||||
|
{
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_min_note_chars > 4096 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring
|
||||||
|
.excluded_usernames
|
||||||
|
.iter()
|
||||||
|
.any(|u| u.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.excludedUsernames entries must be non-empty".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +383,8 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
|||||||
pub struct MinimalConfig {
|
pub struct MinimalConfig {
|
||||||
pub gitlab: MinimalGitLabConfig,
|
pub gitlab: MinimalGitLabConfig,
|
||||||
pub projects: Vec<ProjectConfig>,
|
pub projects: Vec<ProjectConfig>,
|
||||||
|
#[serde(rename = "defaultProject", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_project: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -314,6 +430,31 @@ mod tests {
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_config_with_default_project(
|
||||||
|
dir: &TempDir,
|
||||||
|
default_project: Option<&str>,
|
||||||
|
) -> std::path::PathBuf {
|
||||||
|
let path = dir.path().join("config.json");
|
||||||
|
let dp_field = match default_project {
|
||||||
|
Some(dp) => format!(r#","defaultProject": "{dp}""#),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let config = format!(
|
||||||
|
r#"{{
|
||||||
|
"gitlab": {{
|
||||||
|
"baseUrl": "https://gitlab.example.com",
|
||||||
|
"tokenEnvVar": "GITLAB_TOKEN"
|
||||||
|
}},
|
||||||
|
"projects": [
|
||||||
|
{{ "path": "group/project" }},
|
||||||
|
{{ "path": "other/repo" }}
|
||||||
|
]{dp_field}
|
||||||
|
}}"#
|
||||||
|
);
|
||||||
|
fs::write(&path, config).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_rejects_negative_author_weight() {
|
fn test_load_rejects_negative_author_weight() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -383,4 +524,266 @@ mod tests {
|
|||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}");
|
assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_project_cli_overrides_default() {
|
||||||
|
let config = Config {
|
||||||
|
gitlab: GitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: Some("group/project".to_string()),
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
storage: StorageConfig::default(),
|
||||||
|
embedding: EmbeddingConfig::default(),
|
||||||
|
logging: LoggingConfig::default(),
|
||||||
|
scoring: ScoringConfig::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
config.effective_project(Some("other/repo")),
|
||||||
|
Some("other/repo")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_project_falls_back_to_default() {
|
||||||
|
let config = Config {
|
||||||
|
gitlab: GitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: Some("group/project".to_string()),
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
storage: StorageConfig::default(),
|
||||||
|
embedding: EmbeddingConfig::default(),
|
||||||
|
logging: LoggingConfig::default(),
|
||||||
|
scoring: ScoringConfig::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(config.effective_project(None), Some("group/project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_project_none_when_both_absent() {
|
||||||
|
let config = Config {
|
||||||
|
gitlab: GitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: None,
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
storage: StorageConfig::default(),
|
||||||
|
embedding: EmbeddingConfig::default(),
|
||||||
|
logging: LoggingConfig::default(),
|
||||||
|
scoring: ScoringConfig::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(config.effective_project(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_with_valid_default_project() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config_with_default_project(&dir, Some("group/project"));
|
||||||
|
let config = Config::load_from_path(&path).unwrap();
|
||||||
|
assert_eq!(config.default_project.as_deref(), Some("group/project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_rejects_invalid_default_project() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config_with_default_project(&dir, Some("nonexistent/project"));
|
||||||
|
let err = Config::load_from_path(&path).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("defaultProject"), "unexpected error: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_default_project_suffix_match() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config_with_default_project(&dir, Some("project"));
|
||||||
|
let config = Config::load_from_path(&path).unwrap();
|
||||||
|
assert_eq!(config.default_project.as_deref(), Some("project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_config_omits_null_default_project() {
|
||||||
|
let config = MinimalConfig {
|
||||||
|
gitlab: MinimalGitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
assert!(
|
||||||
|
!json.contains("defaultProject"),
|
||||||
|
"null default_project should be omitted: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_config_includes_default_project_when_set() {
|
||||||
|
let config = MinimalConfig {
|
||||||
|
gitlab: MinimalGitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: Some("group/project".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains("defaultProject"),
|
||||||
|
"set default_project should be present: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_zero_half_life() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
author_half_life_days: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("authorHalfLifeDays"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_absurd_half_life() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
author_half_life_days: 5000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("authorHalfLifeDays"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_nan_multiplier() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
closed_mr_multiplier: f64::NAN,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("closedMrMultiplier"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_zero_multiplier() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
closed_mr_multiplier: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("closedMrMultiplier"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_negative_reviewer_assignment_weight() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
reviewer_assignment_weight: -1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("reviewerAssignmentWeight"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_oversized_min_note_chars() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
reviewer_min_note_chars: 5000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("reviewerMinNoteChars"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_empty_excluded_username() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
excluded_usernames: vec!["valid".to_string(), " ".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("excludedUsernames"), "unexpected error: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_accepts_valid_new_fields() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
author_half_life_days: 365,
|
||||||
|
reviewer_half_life_days: 180,
|
||||||
|
reviewer_assignment_half_life_days: 90,
|
||||||
|
note_half_life_days: 60,
|
||||||
|
closed_mr_multiplier: 0.5,
|
||||||
|
reviewer_min_note_chars: 20,
|
||||||
|
reviewer_assignment_weight: 3,
|
||||||
|
excluded_usernames: vec!["bot-user".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_accepts_boundary_half_life() {
|
||||||
|
// 1 and 3650 are both valid boundaries
|
||||||
|
let scoring_min = ScoringConfig {
|
||||||
|
author_half_life_days: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring_min).unwrap();
|
||||||
|
|
||||||
|
let scoring_max = ScoringConfig {
|
||||||
|
author_half_life_days: 3650,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring_max).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_accepts_multiplier_at_one() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
closed_mr_multiplier: 1.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,26 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
"021",
|
"021",
|
||||||
include_str!("../../migrations/021_work_item_status.sql"),
|
include_str!("../../migrations/021_work_item_status.sql"),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"022",
|
||||||
|
include_str!("../../migrations/022_notes_query_index.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"023",
|
||||||
|
include_str!("../../migrations/023_issue_detail_fields.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"024",
|
||||||
|
include_str!("../../migrations/024_note_documents.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"025",
|
||||||
|
include_str!("../../migrations/025_note_dirty_backfill.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"026",
|
||||||
|
include_str!("../../migrations/026_scoring_indexes.sql"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||||
@@ -312,3 +332,7 @@ pub fn get_schema_version(conn: &Connection) -> i32 {
|
|||||||
)
|
)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "db_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
632
src/core/db_tests.rs
Normal file
632
src/core/db_tests.rs
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn setup_migrated_db() -> Connection {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_exists(conn: &Connection, index_name: &str) -> bool {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
|
||||||
|
[index_name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
|
||||||
|
let sql = format!("PRAGMA table_info({})", table);
|
||||||
|
let mut stmt = conn.prepare(&sql).unwrap();
|
||||||
|
let columns: Vec<String> = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
columns.contains(&column.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_022_indexes_exist() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
|
||||||
|
// New indexes from migration 022
|
||||||
|
assert!(
|
||||||
|
index_exists(&conn, "idx_notes_user_created"),
|
||||||
|
"idx_notes_user_created should exist"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
index_exists(&conn, "idx_notes_project_created"),
|
||||||
|
"idx_notes_project_created should exist"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
index_exists(&conn, "idx_notes_author_id"),
|
||||||
|
"idx_notes_author_id should exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Discussion JOIN indexes (idx_discussions_issue_id is new;
|
||||||
|
// idx_discussions_mr_id already existed from migration 006 but
|
||||||
|
// IF NOT EXISTS makes it safe)
|
||||||
|
assert!(
|
||||||
|
index_exists(&conn, "idx_discussions_issue_id"),
|
||||||
|
"idx_discussions_issue_id should exist"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
index_exists(&conn, "idx_discussions_mr_id"),
|
||||||
|
"idx_discussions_mr_id should exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
// author_id column on notes
|
||||||
|
assert!(
|
||||||
|
column_exists(&conn, "notes", "author_id"),
|
||||||
|
"notes.author_id column should exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helper: insert a minimal project for FK satisfaction --
|
||||||
|
fn insert_test_project(conn: &Connection) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \
|
||||||
|
VALUES (1000, 'test/project', 'https://example.com/test/project')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helper: insert a minimal issue --
|
||||||
|
fn insert_test_issue(conn: &Connection, project_id: i64) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (gitlab_id, project_id, iid, state, author_username, \
|
||||||
|
created_at, updated_at, last_seen_at) \
|
||||||
|
VALUES (100, ?1, 1, 'opened', 'alice', 1000, 1000, 1000)",
|
||||||
|
[project_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helper: insert a minimal discussion --
|
||||||
|
fn insert_test_discussion(conn: &Connection, project_id: i64, issue_id: i64) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, \
|
||||||
|
noteable_type, last_seen_at) \
|
||||||
|
VALUES ('disc-001', ?1, ?2, 'Issue', 1000)",
|
||||||
|
rusqlite::params![project_id, issue_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helper: insert a minimal non-system note --
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn insert_test_note(
|
||||||
|
conn: &Connection,
|
||||||
|
gitlab_id: i64,
|
||||||
|
discussion_id: i64,
|
||||||
|
project_id: i64,
|
||||||
|
is_system: bool,
|
||||||
|
) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, \
|
||||||
|
author_username, body, created_at, updated_at, last_seen_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, 'alice', 'note body', 1000, 1000, 1000)",
|
||||||
|
rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helper: insert a document --
|
||||||
|
fn insert_test_document(
|
||||||
|
conn: &Connection,
|
||||||
|
source_type: &str,
|
||||||
|
source_id: i64,
|
||||||
|
project_id: i64,
|
||||||
|
) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||||
|
VALUES (?1, ?2, ?3, 'test content', 'hash123')",
|
||||||
|
rusqlite::params![source_type, source_id, project_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_allows_note_source_type() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
|
||||||
|
// Should succeed -- 'note' is now allowed
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||||
|
VALUES ('note', 1, ?1, 'note content', 'hash-note')",
|
||||||
|
[pid],
|
||||||
|
)
|
||||||
|
.expect("INSERT with source_type='note' into documents should succeed");
|
||||||
|
|
||||||
|
// dirty_sources should also accept 'note'
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dirty_sources (source_type, source_id, queued_at) \
|
||||||
|
VALUES ('note', 1, 1000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("INSERT with source_type='note' into dirty_sources should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_preserves_existing_data() {
|
||||||
|
// Run migrations up to 023 only, insert data, then apply 024
|
||||||
|
// Migration 024 is at index 23 (0-based). Use hardcoded index so adding
|
||||||
|
// later migrations doesn't silently shift what this test exercises.
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
|
||||||
|
// Apply migrations 001-023 (indices 0..23)
|
||||||
|
run_migrations_up_to(&conn, 23);
|
||||||
|
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
|
||||||
|
// Insert a document with existing source_type
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash, title) \
|
||||||
|
VALUES ('issue', 1, ?1, 'issue content', 'hash-issue', 'Test Issue')",
|
||||||
|
[pid],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let doc_id: i64 = conn.last_insert_rowid();
|
||||||
|
|
||||||
|
// Insert junction data
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_labels (document_id, label_name) VALUES (?1, 'bug')",
|
||||||
|
[doc_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_paths (document_id, path) VALUES (?1, 'src/main.rs')",
|
||||||
|
[doc_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert dirty_sources row
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('issue', 1, 1000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Now apply migration 024 (index 23) -- the table-rebuild migration
|
||||||
|
run_single_migration(&conn, 23);
|
||||||
|
|
||||||
|
// Verify document still exists with correct data
|
||||||
|
let (st, content, title): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT source_type, content_text, title FROM documents WHERE id = ?1",
|
||||||
|
[doc_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(st, "issue");
|
||||||
|
assert_eq!(content, "issue content");
|
||||||
|
assert_eq!(title, "Test Issue");
|
||||||
|
|
||||||
|
// Verify junction data preserved
|
||||||
|
let label_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM document_labels WHERE document_id = ?1",
|
||||||
|
[doc_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(label_count, 1);
|
||||||
|
|
||||||
|
let path_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM document_paths WHERE document_id = ?1",
|
||||||
|
[doc_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path_count, 1);
|
||||||
|
|
||||||
|
// Verify dirty_sources preserved
|
||||||
|
let dirty_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dirty_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_fts_triggers_intact() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
|
||||||
|
// Insert a document after migration -- FTS trigger should fire
|
||||||
|
let doc_id = insert_test_document(&conn, "note", 1, pid);
|
||||||
|
|
||||||
|
// Verify FTS entry exists
|
||||||
|
let fts_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(fts_count > 0, "FTS trigger should have created an entry");
|
||||||
|
|
||||||
|
// Verify update trigger works
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE documents SET content_text = 'updated content' WHERE id = ?1",
|
||||||
|
[doc_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fts_updated: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
fts_updated > 0,
|
||||||
|
"FTS update trigger should reflect new content"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify delete trigger works
|
||||||
|
conn.execute("DELETE FROM documents WHERE id = ?1", [doc_id])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fts_after_delete: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fts_after_delete, 0,
|
||||||
|
"FTS delete trigger should remove the entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_row_counts_preserved() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
|
||||||
|
// After full migration, tables should exist and be queryable
|
||||||
|
let doc_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(doc_count, 0, "Fresh DB should have 0 documents");
|
||||||
|
|
||||||
|
let dirty_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dirty_count, 0, "Fresh DB should have 0 dirty_sources");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_integrity_checks_pass() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
|
||||||
|
// PRAGMA integrity_check
|
||||||
|
let integrity: String = conn
|
||||||
|
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(integrity, "ok", "Database integrity check should pass");
|
||||||
|
|
||||||
|
// PRAGMA foreign_key_check (returns rows only if there are violations)
|
||||||
|
let fk_violations: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fk_violations, 0, "No foreign key violations should exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_note_delete_trigger_cleans_document() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
let issue_id = insert_test_issue(&conn, pid);
|
||||||
|
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||||
|
let note_id = insert_test_note(&conn, 200, disc_id, pid, false);
|
||||||
|
|
||||||
|
// Create a document for this note
|
||||||
|
insert_test_document(&conn, "note", note_id, pid);
|
||||||
|
|
||||||
|
let doc_before: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(doc_before, 1);
|
||||||
|
|
||||||
|
// Delete the note -- trigger should remove the document
|
||||||
|
conn.execute("DELETE FROM notes WHERE id = ?1", [note_id])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let doc_after: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
doc_after, 0,
|
||||||
|
"notes_ad_cleanup trigger should delete the document"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_note_system_flip_trigger_cleans_document() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
let issue_id = insert_test_issue(&conn, pid);
|
||||||
|
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||||
|
let note_id = insert_test_note(&conn, 201, disc_id, pid, false);
|
||||||
|
|
||||||
|
// Create a document for this note
|
||||||
|
insert_test_document(&conn, "note", note_id, pid);
|
||||||
|
|
||||||
|
let doc_before: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(doc_before, 1);
|
||||||
|
|
||||||
|
// Flip is_system from 0 to 1 -- trigger should remove the document
|
||||||
|
conn.execute("UPDATE notes SET is_system = 1 WHERE id = ?1", [note_id])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let doc_after: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
doc_after, 0,
|
||||||
|
"notes_au_system_cleanup trigger should delete the document"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_024_system_note_delete_trigger_does_not_fire() {
|
||||||
|
let conn = setup_migrated_db();
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
let issue_id = insert_test_issue(&conn, pid);
|
||||||
|
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||||
|
|
||||||
|
// Insert a system note (is_system = true)
|
||||||
|
let note_id = insert_test_note(&conn, 202, disc_id, pid, true);
|
||||||
|
|
||||||
|
// Manually insert a document (shouldn't exist for system notes in practice,
|
||||||
|
// but we test the trigger guard)
|
||||||
|
insert_test_document(&conn, "note", note_id, pid);
|
||||||
|
|
||||||
|
let doc_before: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(doc_before, 1);
|
||||||
|
|
||||||
|
// Delete system note -- trigger has WHEN old.is_system = 0 so it should NOT fire
|
||||||
|
conn.execute("DELETE FROM notes WHERE id = ?1", [note_id])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let doc_after: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
doc_after, 1,
|
||||||
|
"notes_ad_cleanup trigger should NOT fire for system notes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run migrations only up to version `up_to` (inclusive).
|
||||||
|
fn run_migrations_up_to(conn: &Connection, up_to: usize) {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_version ( \
|
||||||
|
version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (version_str, sql) in &MIGRATIONS[..up_to] {
|
||||||
|
let version: i32 = version_str.parse().unwrap();
|
||||||
|
conn.execute_batch(sql).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO schema_version (version, applied_at, description) \
|
||||||
|
VALUES (?1, strftime('%s', 'now') * 1000, ?2)",
|
||||||
|
rusqlite::params![version, version_str],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a single migration by index (0-based).
|
||||||
|
fn run_single_migration(conn: &Connection, index: usize) {
|
||||||
|
let (version_str, sql) = MIGRATIONS[index];
|
||||||
|
let version: i32 = version_str.parse().unwrap();
|
||||||
|
conn.execute_batch(sql).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO schema_version (version, applied_at, description) \
|
||||||
|
VALUES (?1, strftime('%s', 'now') * 1000, ?2)",
|
||||||
|
rusqlite::params![version, version_str],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_025_backfills_existing_notes() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
// Run all migrations through 024 (index 0..24)
|
||||||
|
run_migrations_up_to(&conn, 24);
|
||||||
|
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
let issue_id = insert_test_issue(&conn, pid);
|
||||||
|
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||||
|
|
||||||
|
// Insert 5 non-system notes
|
||||||
|
for i in 1..=5 {
|
||||||
|
insert_test_note(&conn, 300 + i, disc_id, pid, false);
|
||||||
|
}
|
||||||
|
// Insert 2 system notes
|
||||||
|
for i in 1..=2 {
|
||||||
|
insert_test_note(&conn, 400 + i, disc_id, pid, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration 025
|
||||||
|
run_single_migration(&conn, 24);
|
||||||
|
|
||||||
|
let dirty_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
dirty_count, 5,
|
||||||
|
"Migration 025 should backfill 5 non-system notes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify system notes were not backfilled
|
||||||
|
let system_note_ids: Vec<i64> = {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT source_id FROM dirty_sources WHERE source_type = 'note' ORDER BY source_id",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_map([], |row| row.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
// System note ids should not appear
|
||||||
|
let all_system_note_ids: Vec<i64> = {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id FROM notes WHERE is_system = 1 ORDER BY id")
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_map([], |row| row.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
for sys_id in &all_system_note_ids {
|
||||||
|
assert!(
|
||||||
|
!system_note_ids.contains(sys_id),
|
||||||
|
"System note id {} should not be in dirty_sources",
|
||||||
|
sys_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_025_idempotent_with_existing_documents() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations_up_to(&conn, 24);
|
||||||
|
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
let issue_id = insert_test_issue(&conn, pid);
|
||||||
|
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||||
|
|
||||||
|
// Insert 3 non-system notes
|
||||||
|
let note_ids: Vec<i64> = (1..=3)
|
||||||
|
.map(|i| insert_test_note(&conn, 500 + i, disc_id, pid, false))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Create documents for 2 of 3 notes (simulating already-generated docs)
|
||||||
|
insert_test_document(&conn, "note", note_ids[0], pid);
|
||||||
|
insert_test_document(&conn, "note", note_ids[1], pid);
|
||||||
|
|
||||||
|
// Run migration 025
|
||||||
|
run_single_migration(&conn, 24);
|
||||||
|
|
||||||
|
let dirty_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
dirty_count, 1,
|
||||||
|
"Only the note without a document should be backfilled"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the correct note was queued
|
||||||
|
let queued_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT source_id FROM dirty_sources WHERE source_type = 'note'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(queued_id, note_ids[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_025_skips_notes_already_in_dirty_queue() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations_up_to(&conn, 24);
|
||||||
|
|
||||||
|
let pid = insert_test_project(&conn);
|
||||||
|
let issue_id = insert_test_issue(&conn, pid);
|
||||||
|
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||||
|
|
||||||
|
// Insert 3 non-system notes
|
||||||
|
let note_ids: Vec<i64> = (1..=3)
|
||||||
|
.map(|i| insert_test_note(&conn, 600 + i, disc_id, pid, false))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Pre-queue one note in dirty_sources
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('note', ?1, 999)",
|
||||||
|
[note_ids[0]],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Run migration 025
|
||||||
|
run_single_migration(&conn, 24);
|
||||||
|
|
||||||
|
let dirty_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
dirty_count, 3,
|
||||||
|
"All 3 notes should be in dirty_sources (1 pre-existing + 2 new)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the pre-existing entry preserved its original queued_at
|
||||||
|
let original_queued_at: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT queued_at FROM dirty_sources WHERE source_type = 'note' AND source_id = ?1",
|
||||||
|
[note_ids[0]],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
original_queued_at, 999,
|
||||||
|
"ON CONFLICT DO NOTHING should preserve the original queued_at"
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/core/file_history.rs
Normal file
71
src/core/file_history.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use super::error::Result;
|
||||||
|
|
||||||
|
/// Resolves a file path through its rename history in `mr_file_changes`.
|
||||||
|
///
|
||||||
|
/// BFS in both directions: forward (`old_path` -> `new_path`) and backward
|
||||||
|
/// (`new_path` -> `old_path`). Returns all equivalent paths including the
|
||||||
|
/// original, sorted for determinism. Cycles are detected via a visited set.
|
||||||
|
///
|
||||||
|
/// `max_hops` limits the BFS depth (distance from the starting path).
|
||||||
|
pub fn resolve_rename_chain(
|
||||||
|
conn: &Connection,
|
||||||
|
project_id: i64,
|
||||||
|
path: &str,
|
||||||
|
max_hops: usize,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let mut visited: HashSet<String> = HashSet::new();
|
||||||
|
visited.insert(path.to_string());
|
||||||
|
|
||||||
|
if max_hops == 0 {
|
||||||
|
return Ok(vec![path.to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
|
||||||
|
queue.push_back((path.to_string(), 0));
|
||||||
|
|
||||||
|
let forward_sql = "\
|
||||||
|
SELECT DISTINCT mfc.new_path FROM mr_file_changes mfc \
|
||||||
|
WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'";
|
||||||
|
let backward_sql = "\
|
||||||
|
SELECT DISTINCT mfc.old_path FROM mr_file_changes mfc \
|
||||||
|
WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'";
|
||||||
|
|
||||||
|
while let Some((current, depth)) = queue.pop_front() {
|
||||||
|
if depth >= max_hops {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward: current was the old name -> discover new names
|
||||||
|
let mut fwd_stmt = conn.prepare_cached(forward_sql)?;
|
||||||
|
let forward: Vec<String> = fwd_stmt
|
||||||
|
.query_map(rusqlite::params![project_id, ¤t], |row| row.get(0))?
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Backward: current was the new name -> discover old names
|
||||||
|
let mut bwd_stmt = conn.prepare_cached(backward_sql)?;
|
||||||
|
let backward: Vec<String> = bwd_stmt
|
||||||
|
.query_map(rusqlite::params![project_id, ¤t], |row| row.get(0))?
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for discovered in forward.into_iter().chain(backward) {
|
||||||
|
if visited.insert(discovered.clone()) {
|
||||||
|
queue.push_back((discovered, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths: Vec<String> = visited.into_iter().collect();
|
||||||
|
paths.sort();
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "file_history_tests.rs"]
|
||||||
|
mod tests;
|
||||||
274
src/core/file_history_tests.rs
Normal file
274
src/core/file_history_tests.rs
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::core::db::{create_connection, run_migrations};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn setup_test_db() -> Connection {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_project(conn: &Connection) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'group/repo', 'https://gitlab.example.com/group/repo', 1000, 2000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (1, 300, 5, 1, 'Rename MR', 'merged', 1000, 2000, 2000, 'feature', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
1 // project_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_rename(conn: &Connection, mr_id: i64, old_path: &str, new_path: &str) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'renamed')",
|
||||||
|
rusqlite::params![mr_id, old_path, new_path],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_renames_returns_original_path() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
let result = resolve_rename_chain(&conn, project_id, "src/auth.rs", 10).unwrap();
|
||||||
|
assert_eq!(result, ["src/auth.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_forward_chain() {
|
||||||
|
// a.rs -> b.rs -> c.rs, starting from a.rs finds all three
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
// Need a second MR for the next rename
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (2, 301, 6, 1, 'Rename MR 2', 'merged', 3000, 4000, 4000, 'feature2', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 2, "src/b.rs", "src/c.rs");
|
||||||
|
|
||||||
|
let mut result = resolve_rename_chain(&conn, project_id, "src/a.rs", 10).unwrap();
|
||||||
|
result.sort();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs", "src/c.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backward_chain() {
|
||||||
|
// a.rs -> b.rs -> c.rs, starting from c.rs finds all three
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (2, 301, 6, 1, 'Rename MR 2', 'merged', 3000, 4000, 4000, 'feature2', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 2, "src/b.rs", "src/c.rs");
|
||||||
|
|
||||||
|
let mut result = resolve_rename_chain(&conn, project_id, "src/c.rs", 10).unwrap();
|
||||||
|
result.sort();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs", "src/c.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cycle_detection() {
|
||||||
|
// a -> b -> a: terminates without infinite loop
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (2, 301, 6, 1, 'Rename back', 'merged', 3000, 4000, 4000, 'feature2', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 2, "src/b.rs", "src/a.rs");
|
||||||
|
|
||||||
|
let mut result = resolve_rename_chain(&conn, project_id, "src/a.rs", 10).unwrap();
|
||||||
|
result.sort();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_hops_zero_returns_original() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
let result = resolve_rename_chain(&conn, project_id, "src/a.rs", 0).unwrap();
|
||||||
|
assert_eq!(result, ["src/a.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_hops_bounded() {
|
||||||
|
// Chain: a -> b -> c -> d -> e (4 hops)
|
||||||
|
// With max_hops=2, should find exactly {a, b, c} (original + 2 depth levels)
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
let paths = ["src/a.rs", "src/b.rs", "src/c.rs", "src/d.rs", "src/e.rs"];
|
||||||
|
for (i, window) in paths.windows(2).enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (?1, ?2, ?3, 1, 'MR', 'merged', ?4, ?5, ?5, 'feat', 'main')",
|
||||||
|
rusqlite::params![
|
||||||
|
(i + 1) as i64,
|
||||||
|
(300 + i) as i64,
|
||||||
|
(5 + i) as i64,
|
||||||
|
(1000 * (i + 1)) as i64,
|
||||||
|
(2000 * (i + 1)) as i64,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
#[allow(clippy::cast_possible_wrap)]
|
||||||
|
insert_rename(&conn, (i + 1) as i64, window[0], window[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = resolve_rename_chain(&conn, project_id, "src/a.rs", 2).unwrap();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs", "src/c.rs"]);
|
||||||
|
|
||||||
|
// Depth 1 should find only {a, b}
|
||||||
|
let result1 = resolve_rename_chain(&conn, project_id, "src/a.rs", 1).unwrap();
|
||||||
|
assert_eq!(result1, ["src/a.rs", "src/b.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_diamond_pattern() {
|
||||||
|
// Diamond: a -> b, a -> c, b -> d, c -> d
|
||||||
|
// From a with max_hops=2, should find all four: {a, b, c, d}
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
// MR 1: a -> b
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
// MR 2: a -> c
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (2, 301, 6, 1, 'MR 2', 'merged', 2000, 3000, 3000, 'feat2', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 2, "src/a.rs", "src/c.rs");
|
||||||
|
|
||||||
|
// MR 3: b -> d
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (3, 302, 7, 1, 'MR 3', 'merged', 3000, 4000, 4000, 'feat3', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 3, "src/b.rs", "src/d.rs");
|
||||||
|
|
||||||
|
// MR 4: c -> d
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (4, 303, 8, 1, 'MR 4', 'merged', 4000, 5000, 5000, 'feat4', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 4, "src/c.rs", "src/d.rs");
|
||||||
|
|
||||||
|
// max_hops=2: a(0) -> {b,c}(1) -> {d}(2) — all four found
|
||||||
|
let result = resolve_rename_chain(&conn, project_id, "src/a.rs", 2).unwrap();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs", "src/c.rs", "src/d.rs"]);
|
||||||
|
|
||||||
|
// max_hops=1: a(0) -> {b,c}(1) — d at depth 2 excluded
|
||||||
|
let result1 = resolve_rename_chain(&conn, project_id, "src/a.rs", 1).unwrap();
|
||||||
|
assert_eq!(result1, ["src/a.rs", "src/b.rs", "src/c.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branching_renames() {
|
||||||
|
// a.rs was renamed to b.rs in one MR and c.rs in another
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (2, 301, 6, 1, 'Rename MR 2', 'merged', 3000, 4000, 4000, 'feature2', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_rename(&conn, 2, "src/a.rs", "src/c.rs");
|
||||||
|
|
||||||
|
let mut result = resolve_rename_chain(&conn, project_id, "src/a.rs", 10).unwrap();
|
||||||
|
result.sort();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs", "src/c.rs"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_project_isolation() {
|
||||||
|
// Renames in project 2 should not leak into project 1 queries
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let _project_id = seed_project(&conn);
|
||||||
|
|
||||||
|
// Create project 2
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (2, 200, 'other/repo', 'https://gitlab.example.com/other/repo', 1000, 2000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (2, 301, 5, 2, 'Other MR', 'merged', 1000, 2000, 2000, 'feat', 'main')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Rename in project 1
|
||||||
|
insert_rename(&conn, 1, "src/a.rs", "src/b.rs");
|
||||||
|
|
||||||
|
// Rename in project 2 (different mr_id and project_id)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
|
||||||
|
VALUES (2, 2, 'src/a.rs', 'src/z.rs', 'renamed')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Query project 1 -- should NOT see z.rs
|
||||||
|
let mut result = resolve_rename_chain(&conn, 1, "src/a.rs", 10).unwrap();
|
||||||
|
result.sort();
|
||||||
|
assert_eq!(result, ["src/a.rs", "src/b.rs"]);
|
||||||
|
|
||||||
|
// Query project 2 -- should NOT see b.rs
|
||||||
|
let mut result2 = resolve_rename_chain(&conn, 2, "src/a.rs", 10).unwrap();
|
||||||
|
result2.sort();
|
||||||
|
assert_eq!(result2, ["src/a.rs", "src/z.rs"]);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::db::create_connection;
|
use super::db::create_connection;
|
||||||
@@ -75,7 +75,7 @@ impl AppLock {
|
|||||||
"INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)",
|
||||||
(&self.name, &self.owner, now, now),
|
(&self.name, &self.owner, now, now),
|
||||||
)?;
|
)?;
|
||||||
info!(owner = %self.owner, "Lock acquired (new)");
|
debug!(owner = %self.owner, "Lock acquired (new)");
|
||||||
}
|
}
|
||||||
Some((existing_owner, acquired_at, heartbeat_at)) => {
|
Some((existing_owner, acquired_at, heartbeat_at)) => {
|
||||||
let is_stale = now - heartbeat_at > self.stale_lock_ms;
|
let is_stale = now - heartbeat_at > self.stale_lock_ms;
|
||||||
@@ -85,7 +85,7 @@ impl AppLock {
|
|||||||
"UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?",
|
"UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?",
|
||||||
(&self.owner, now, now, &self.name),
|
(&self.owner, now, now, &self.name),
|
||||||
)?;
|
)?;
|
||||||
info!(
|
debug!(
|
||||||
owner = %self.owner,
|
owner = %self.owner,
|
||||||
previous_owner = %existing_owner,
|
previous_owner = %existing_owner,
|
||||||
was_stale = is_stale,
|
was_stale = is_stale,
|
||||||
@@ -125,7 +125,7 @@ impl AppLock {
|
|||||||
"DELETE FROM app_locks WHERE name = ? AND owner = ?",
|
"DELETE FROM app_locks WHERE name = ? AND owner = ?",
|
||||||
(&self.name, &self.owner),
|
(&self.name, &self.owner),
|
||||||
) {
|
) {
|
||||||
Ok(_) => info!(owner = %self.owner, "Lock released"),
|
Ok(_) => debug!(owner = %self.owner, "Lock released"),
|
||||||
Err(e) => error!(
|
Err(e) => error!(
|
||||||
owner = %self.owner,
|
owner = %self.owner,
|
||||||
error = %e,
|
error = %e,
|
||||||
|
|||||||
@@ -1,7 +1,45 @@
|
|||||||
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields};
|
||||||
|
use tracing_subscriber::registry::LookupSpan;
|
||||||
|
|
||||||
|
/// Compact stderr formatter: `HH:MM:SS LEVEL message key=value`
|
||||||
|
///
|
||||||
|
/// No span context, no full timestamps, no target — just the essentials.
|
||||||
|
/// The JSON file log is unaffected (it uses its own layer).
|
||||||
|
pub struct CompactHumanFormat;
|
||||||
|
|
||||||
|
impl<S, N> FormatEvent<S, N> for CompactHumanFormat
|
||||||
|
where
|
||||||
|
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||||
|
N: for<'a> FormatFields<'a> + 'static,
|
||||||
|
{
|
||||||
|
fn format_event(
|
||||||
|
&self,
|
||||||
|
ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
|
||||||
|
mut writer: tracing_subscriber::fmt::format::Writer<'_>,
|
||||||
|
event: &tracing::Event<'_>,
|
||||||
|
) -> fmt::Result {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let time = now.format("%H:%M:%S");
|
||||||
|
|
||||||
|
let level = *event.metadata().level();
|
||||||
|
let styled = match level {
|
||||||
|
tracing::Level::ERROR => console::style("ERROR").red().bold(),
|
||||||
|
tracing::Level::WARN => console::style(" WARN").yellow(),
|
||||||
|
tracing::Level::INFO => console::style(" INFO").green(),
|
||||||
|
tracing::Level::DEBUG => console::style("DEBUG").dim(),
|
||||||
|
tracing::Level::TRACE => console::style("TRACE").dim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
write!(writer, "{time} {styled} ")?;
|
||||||
|
ctx.format_fields(writer.by_ref(), event)?;
|
||||||
|
writeln!(writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
||||||
if std::env::var("RUST_LOG").is_ok() {
|
if std::env::var("RUST_LOG").is_ok() {
|
||||||
@@ -13,8 +51,8 @@ pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let directives = match verbose {
|
let directives = match verbose {
|
||||||
0 => "lore=info,warn",
|
0 => "lore=warn",
|
||||||
1 => "lore=debug,warn",
|
1 => "lore=info,warn",
|
||||||
2 => "lore=debug,info",
|
2 => "lore=debug,info",
|
||||||
_ => "lore=trace,debug",
|
_ => "lore=trace,debug",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ pub mod db;
|
|||||||
pub mod dependent_queue;
|
pub mod dependent_queue;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events_db;
|
pub mod events_db;
|
||||||
|
pub mod file_history;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod note_parser;
|
pub mod note_parser;
|
||||||
|
pub mod path_resolver;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod payloads;
|
pub mod payloads;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@@ -19,6 +21,7 @@ pub mod timeline;
|
|||||||
pub mod timeline_collect;
|
pub mod timeline_collect;
|
||||||
pub mod timeline_expand;
|
pub mod timeline_expand;
|
||||||
pub mod timeline_seed;
|
pub mod timeline_seed;
|
||||||
|
pub mod trace;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
pub use error::{LoreError, Result};
|
pub use error::{LoreError, Result};
|
||||||
|
|||||||
@@ -22,20 +22,34 @@ pub struct ExtractResult {
|
|||||||
pub parse_failures: usize,
|
pub parse_failures: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitLab system notes include the entity type word: "mentioned in issue #5"
|
||||||
|
// or "mentioned in merge request !730". The word is mandatory in real data,
|
||||||
|
// but we also keep the old bare-sigil form as a fallback (no data uses it today,
|
||||||
|
// but other GitLab instances might differ).
|
||||||
static MENTIONED_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static MENTIONED_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(
|
Regex::new(
|
||||||
r"mentioned in (?:(?P<project>[\w][\w.\-]*(?:/[\w][\w.\-]*)+))?(?P<sigil>[#!])(?P<iid>\d+)",
|
r"mentioned in (?:issue |merge request )?(?:(?P<project>[\w][\w.\-]*(?:/[\w][\w.\-]*)+))?(?P<sigil>[#!])(?P<iid>\d+)",
|
||||||
)
|
)
|
||||||
.expect("mentioned regex is valid")
|
.expect("mentioned regex is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
static CLOSED_BY_RE: LazyLock<Regex> = LazyLock::new(|| {
|
static CLOSED_BY_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(
|
Regex::new(
|
||||||
r"closed by (?:(?P<project>[\w][\w.\-]*(?:/[\w][\w.\-]*)+))?(?P<sigil>[#!])(?P<iid>\d+)",
|
r"closed by (?:issue |merge request )?(?:(?P<project>[\w][\w.\-]*(?:/[\w][\w.\-]*)+))?(?P<sigil>[#!])(?P<iid>\d+)",
|
||||||
)
|
)
|
||||||
.expect("closed_by regex is valid")
|
.expect("closed_by regex is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Matches full GitLab URLs like:
|
||||||
|
/// `https://gitlab.example.com/group/project/-/issues/123`
|
||||||
|
/// `https://gitlab.example.com/group/sub/project/-/merge_requests/456`
|
||||||
|
static GITLAB_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"https?://[^\s/]+/(?P<project>[^\s]+?)/-/(?P<entity_type>issues|merge_requests)/(?P<iid>\d+)",
|
||||||
|
)
|
||||||
|
.expect("gitlab url regex is valid")
|
||||||
|
});
|
||||||
|
|
||||||
pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef> {
|
pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef> {
|
||||||
let mut refs = Vec::new();
|
let mut refs = Vec::new();
|
||||||
|
|
||||||
@@ -54,6 +68,47 @@ pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef> {
|
|||||||
refs
|
refs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract cross-references from GitLab URLs in free-text bodies (descriptions, user notes).
|
||||||
|
pub fn parse_url_refs(body: &str) -> Vec<ParsedCrossRef> {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for caps in GITLAB_URL_RE.captures_iter(body) {
|
||||||
|
let Some(entity_type_raw) = caps.name("entity_type").map(|m| m.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(iid_str) = caps.name("iid").map(|m| m.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(project) = caps.name("project").map(|m| m.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(iid) = iid_str.parse::<i64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_entity_type = match entity_type_raw {
|
||||||
|
"issues" => "issue",
|
||||||
|
"merge_requests" => "merge_request",
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = (target_entity_type, project.to_owned(), iid);
|
||||||
|
if !seen.insert(key) {
|
||||||
|
continue; // deduplicate within same body
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.push(ParsedCrossRef {
|
||||||
|
reference_type: "mentioned".to_owned(),
|
||||||
|
target_entity_type: target_entity_type.to_owned(),
|
||||||
|
target_iid: iid,
|
||||||
|
target_project_path: Some(project.to_owned()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
|
||||||
fn capture_to_cross_ref(
|
fn capture_to_cross_ref(
|
||||||
caps: ®ex::Captures<'_>,
|
caps: ®ex::Captures<'_>,
|
||||||
reference_type: &str,
|
reference_type: &str,
|
||||||
@@ -233,331 +288,189 @@ fn resolve_cross_project_entity(
|
|||||||
resolve_entity_id(conn, project_id, entity_type, iid)
|
resolve_entity_id(conn, project_id, entity_type, iid)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// Extract cross-references from issue and MR descriptions (GitLab URLs only).
|
||||||
mod tests {
|
pub fn extract_refs_from_descriptions(conn: &Connection, project_id: i64) -> Result<ExtractResult> {
|
||||||
use super::*;
|
let mut result = ExtractResult::default();
|
||||||
|
|
||||||
#[test]
|
let mut insert_stmt = conn.prepare_cached(
|
||||||
fn test_parse_mentioned_in_mr() {
|
"INSERT OR IGNORE INTO entity_references
|
||||||
let refs = parse_cross_refs("mentioned in !567");
|
(project_id, source_entity_type, source_entity_id,
|
||||||
assert_eq!(refs.len(), 1);
|
target_entity_type, target_entity_id,
|
||||||
assert_eq!(refs[0].reference_type, "mentioned");
|
target_project_path, target_entity_iid,
|
||||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
reference_type, source_method, created_at)
|
||||||
assert_eq!(refs[0].target_iid, 567);
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'description_parse', ?9)",
|
||||||
assert!(refs[0].target_project_path.is_none());
|
)?;
|
||||||
|
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
// Issues with descriptions
|
||||||
|
let mut issue_stmt = conn.prepare_cached(
|
||||||
|
"SELECT id, iid, description FROM issues
|
||||||
|
WHERE project_id = ?1 AND description IS NOT NULL AND description != ''",
|
||||||
|
)?;
|
||||||
|
let issues: Vec<(i64, i64, String)> = issue_stmt
|
||||||
|
.query_map([project_id], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
for (entity_id, _iid, description) in &issues {
|
||||||
|
insert_url_refs(
|
||||||
|
conn,
|
||||||
|
&mut insert_stmt,
|
||||||
|
&mut result,
|
||||||
|
project_id,
|
||||||
|
"issue",
|
||||||
|
*entity_id,
|
||||||
|
description,
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// Merge requests with descriptions
|
||||||
fn test_parse_mentioned_in_issue() {
|
let mut mr_stmt = conn.prepare_cached(
|
||||||
let refs = parse_cross_refs("mentioned in #234");
|
"SELECT id, iid, description FROM merge_requests
|
||||||
assert_eq!(refs.len(), 1);
|
WHERE project_id = ?1 AND description IS NOT NULL AND description != ''",
|
||||||
assert_eq!(refs[0].reference_type, "mentioned");
|
)?;
|
||||||
assert_eq!(refs[0].target_entity_type, "issue");
|
let mrs: Vec<(i64, i64, String)> = mr_stmt
|
||||||
assert_eq!(refs[0].target_iid, 234);
|
.query_map([project_id], |row| {
|
||||||
assert!(refs[0].target_project_path.is_none());
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
for (entity_id, _iid, description) in &mrs {
|
||||||
|
insert_url_refs(
|
||||||
|
conn,
|
||||||
|
&mut insert_stmt,
|
||||||
|
&mut result,
|
||||||
|
project_id,
|
||||||
|
"merge_request",
|
||||||
|
*entity_id,
|
||||||
|
description,
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
if result.inserted > 0 || result.skipped_unresolvable > 0 {
|
||||||
fn test_parse_mentioned_cross_project() {
|
debug!(
|
||||||
let refs = parse_cross_refs("mentioned in group/repo!789");
|
inserted = result.inserted,
|
||||||
assert_eq!(refs.len(), 1);
|
unresolvable = result.skipped_unresolvable,
|
||||||
assert_eq!(refs[0].reference_type, "mentioned");
|
"Description cross-reference extraction complete"
|
||||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
|
||||||
assert_eq!(refs[0].target_iid, 789);
|
|
||||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_mentioned_cross_project_issue() {
|
|
||||||
let refs = parse_cross_refs("mentioned in group/repo#123");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(refs[0].reference_type, "mentioned");
|
|
||||||
assert_eq!(refs[0].target_entity_type, "issue");
|
|
||||||
assert_eq!(refs[0].target_iid, 123);
|
|
||||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_closed_by_mr() {
|
|
||||||
let refs = parse_cross_refs("closed by !567");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(refs[0].reference_type, "closes");
|
|
||||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
|
||||||
assert_eq!(refs[0].target_iid, 567);
|
|
||||||
assert!(refs[0].target_project_path.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_closed_by_cross_project() {
|
|
||||||
let refs = parse_cross_refs("closed by group/repo!789");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(refs[0].reference_type, "closes");
|
|
||||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
|
||||||
assert_eq!(refs[0].target_iid, 789);
|
|
||||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_multiple_refs() {
|
|
||||||
let refs = parse_cross_refs("mentioned in !123 and mentioned in #456");
|
|
||||||
assert_eq!(refs.len(), 2);
|
|
||||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
|
||||||
assert_eq!(refs[0].target_iid, 123);
|
|
||||||
assert_eq!(refs[1].target_entity_type, "issue");
|
|
||||||
assert_eq!(refs[1].target_iid, 456);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_no_refs() {
|
|
||||||
let refs = parse_cross_refs("Updated the description");
|
|
||||||
assert!(refs.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_non_english_note() {
|
|
||||||
let refs = parse_cross_refs("a ajout\u{00e9} l'\u{00e9}tiquette ~bug");
|
|
||||||
assert!(refs.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_multi_level_group_path() {
|
|
||||||
let refs = parse_cross_refs("mentioned in top/sub/project#123");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
refs[0].target_project_path.as_deref(),
|
|
||||||
Some("top/sub/project")
|
|
||||||
);
|
|
||||||
assert_eq!(refs[0].target_iid, 123);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_deeply_nested_group_path() {
|
|
||||||
let refs = parse_cross_refs("mentioned in a/b/c/d/e!42");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("a/b/c/d/e"));
|
|
||||||
assert_eq!(refs[0].target_iid, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_hyphenated_project_path() {
|
|
||||||
let refs = parse_cross_refs("mentioned in my-group/my-project#99");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
refs[0].target_project_path.as_deref(),
|
|
||||||
Some("my-group/my-project")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
Ok(result)
|
||||||
fn test_parse_dotted_project_path() {
|
|
||||||
let refs = parse_cross_refs("mentioned in visiostack.io/backend#123");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
refs[0].target_project_path.as_deref(),
|
|
||||||
Some("visiostack.io/backend")
|
|
||||||
);
|
|
||||||
assert_eq!(refs[0].target_iid, 123);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_dotted_nested_project_path() {
|
|
||||||
let refs = parse_cross_refs("closed by my.org/sub.group/my.project!42");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
refs[0].target_project_path.as_deref(),
|
|
||||||
Some("my.org/sub.group/my.project")
|
|
||||||
);
|
|
||||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
|
||||||
assert_eq!(refs[0].target_iid, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_self_reference_is_valid() {
|
|
||||||
let refs = parse_cross_refs("mentioned in #123");
|
|
||||||
assert_eq!(refs.len(), 1);
|
|
||||||
assert_eq!(refs[0].target_iid, 123);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_mixed_mentioned_and_closed() {
|
|
||||||
let refs = parse_cross_refs("mentioned in !10 and closed by !20");
|
|
||||||
assert_eq!(refs.len(), 2);
|
|
||||||
assert_eq!(refs[0].reference_type, "mentioned");
|
|
||||||
assert_eq!(refs[0].target_iid, 10);
|
|
||||||
assert_eq!(refs[1].reference_type, "closes");
|
|
||||||
assert_eq!(refs[1].target_iid, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_test_db() -> Connection {
|
|
||||||
use crate::core::db::{create_connection, run_migrations};
|
|
||||||
|
|
||||||
let conn = create_connection(std::path::Path::new(":memory:")).unwrap();
|
|
||||||
run_migrations(&conn).unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seed_test_data(conn: &Connection) -> i64 {
|
|
||||||
let now = now_ms();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
|
||||||
VALUES (1, 100, 'group/test-project', 'https://gitlab.com/group/test-project', ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (10, 1000, 1, 123, 'Test Issue', 'opened', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (11, 1001, 1, 456, 'Another Issue', 'opened', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (20, 2000, 1, 789, 'Test MR', 'opened', 'feat', 'main', 'dev', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
|
||||||
VALUES (30, 'disc-aaa', 1, 10, 'Issue', ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, last_seen_at)
|
|
||||||
VALUES (31, 'disc-bbb', 1, 20, 'MergeRequest', ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (40, 4000, 30, 1, 1, 'mentioned in !789', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (41, 4001, 31, 1, 1, 'mentioned in #456', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (42, 4002, 30, 1, 0, 'mentioned in !999', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (43, 4003, 30, 1, 1, 'added label ~bug', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
|
||||||
VALUES (44, 4004, 30, 1, 1, 'mentioned in other/project#999', ?1, ?1, ?1)",
|
|
||||||
[now],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
1
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_refs_from_system_notes_integration() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let project_id = seed_test_data(&conn);
|
|
||||||
|
|
||||||
let result = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(result.inserted, 2, "Two same-project refs should resolve");
|
|
||||||
assert_eq!(
|
|
||||||
result.skipped_unresolvable, 1,
|
|
||||||
"One cross-project ref should be unresolvable"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.parse_failures, 1,
|
|
||||||
"One system note has no cross-ref pattern"
|
|
||||||
);
|
|
||||||
|
|
||||||
let ref_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1 AND source_method = 'note_parse'",
|
|
||||||
[project_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(ref_count, 3, "Should have 3 entity_references rows total");
|
|
||||||
|
|
||||||
let unresolved_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL AND source_method = 'note_parse'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
unresolved_count, 1,
|
|
||||||
"Should have 1 unresolved cross-project ref"
|
|
||||||
);
|
|
||||||
|
|
||||||
let (path, iid): (String, i64) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(path, "other/project");
|
|
||||||
assert_eq!(iid, 999);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_refs_idempotent() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let project_id = seed_test_data(&conn);
|
|
||||||
|
|
||||||
let result1 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
|
||||||
let result2 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(result2.inserted, 0);
|
|
||||||
assert_eq!(result2.skipped_unresolvable, 0);
|
|
||||||
|
|
||||||
let total: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
total,
|
|
||||||
(result1.inserted + result1.skipped_unresolvable) as i64
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_refs_empty_project() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let result = extract_refs_from_system_notes(&conn, 999).unwrap();
|
|
||||||
assert_eq!(result.inserted, 0);
|
|
||||||
assert_eq!(result.skipped_unresolvable, 0);
|
|
||||||
assert_eq!(result.parse_failures, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract cross-references from user (non-system) notes (GitLab URLs only).
|
||||||
|
pub fn extract_refs_from_user_notes(conn: &Connection, project_id: i64) -> Result<ExtractResult> {
|
||||||
|
let mut result = ExtractResult::default();
|
||||||
|
|
||||||
|
let mut note_stmt = conn.prepare_cached(
|
||||||
|
"SELECT n.id, n.body, d.noteable_type,
|
||||||
|
COALESCE(d.issue_id, d.merge_request_id) AS entity_id
|
||||||
|
FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
WHERE n.is_system = 0
|
||||||
|
AND n.project_id = ?1
|
||||||
|
AND n.body IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let notes: Vec<(i64, String, String, i64)> = note_stmt
|
||||||
|
.query_map([project_id], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
if notes.is_empty() {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut insert_stmt = conn.prepare_cached(
|
||||||
|
"INSERT OR IGNORE INTO entity_references
|
||||||
|
(project_id, source_entity_type, source_entity_id,
|
||||||
|
target_entity_type, target_entity_id,
|
||||||
|
target_project_path, target_entity_iid,
|
||||||
|
reference_type, source_method, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'note_parse', ?9)",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
for (_, body, noteable_type, entity_id) in ¬es {
|
||||||
|
let source_entity_type = noteable_type_to_entity_type(noteable_type);
|
||||||
|
insert_url_refs(
|
||||||
|
conn,
|
||||||
|
&mut insert_stmt,
|
||||||
|
&mut result,
|
||||||
|
project_id,
|
||||||
|
source_entity_type,
|
||||||
|
*entity_id,
|
||||||
|
body,
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.inserted > 0 || result.skipped_unresolvable > 0 {
|
||||||
|
debug!(
|
||||||
|
inserted = result.inserted,
|
||||||
|
unresolvable = result.skipped_unresolvable,
|
||||||
|
"User note cross-reference extraction complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared helper: parse URL refs from a body and insert into entity_references.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn insert_url_refs(
|
||||||
|
conn: &Connection,
|
||||||
|
insert_stmt: &mut rusqlite::CachedStatement<'_>,
|
||||||
|
result: &mut ExtractResult,
|
||||||
|
project_id: i64,
|
||||||
|
source_entity_type: &str,
|
||||||
|
source_entity_id: i64,
|
||||||
|
body: &str,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let url_refs = parse_url_refs(body);
|
||||||
|
|
||||||
|
for xref in &url_refs {
|
||||||
|
let target_entity_id = if let Some(ref path) = xref.target_project_path {
|
||||||
|
resolve_cross_project_entity(conn, path, &xref.target_entity_type, xref.target_iid)
|
||||||
|
} else {
|
||||||
|
resolve_entity_id(conn, project_id, &xref.target_entity_type, xref.target_iid)
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows_changed = insert_stmt.execute(rusqlite::params![
|
||||||
|
project_id,
|
||||||
|
source_entity_type,
|
||||||
|
source_entity_id,
|
||||||
|
xref.target_entity_type,
|
||||||
|
target_entity_id,
|
||||||
|
xref.target_project_path,
|
||||||
|
if target_entity_id.is_none() {
|
||||||
|
Some(xref.target_iid)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
xref.reference_type,
|
||||||
|
now,
|
||||||
|
])?;
|
||||||
|
|
||||||
|
if rows_changed > 0 {
|
||||||
|
if target_entity_id.is_none() {
|
||||||
|
result.skipped_unresolvable += 1;
|
||||||
|
} else {
|
||||||
|
result.inserted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "note_parser_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
770
src/core/note_parser_tests.rs
Normal file
770
src/core/note_parser_tests.rs
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// --- parse_cross_refs: real GitLab system note format ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mentioned_in_mr() {
|
||||||
|
let refs = parse_cross_refs("mentioned in merge request !567");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "mentioned");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 567);
|
||||||
|
assert!(refs[0].target_project_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mentioned_in_issue() {
|
||||||
|
let refs = parse_cross_refs("mentioned in issue #234");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "mentioned");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "issue");
|
||||||
|
assert_eq!(refs[0].target_iid, 234);
|
||||||
|
assert!(refs[0].target_project_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mentioned_cross_project() {
|
||||||
|
let refs = parse_cross_refs("mentioned in merge request group/repo!789");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "mentioned");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 789);
|
||||||
|
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mentioned_cross_project_issue() {
|
||||||
|
let refs = parse_cross_refs("mentioned in issue group/repo#123");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "mentioned");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "issue");
|
||||||
|
assert_eq!(refs[0].target_iid, 123);
|
||||||
|
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_closed_by_mr() {
|
||||||
|
let refs = parse_cross_refs("closed by merge request !567");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "closes");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 567);
|
||||||
|
assert!(refs[0].target_project_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_closed_by_cross_project() {
|
||||||
|
let refs = parse_cross_refs("closed by merge request group/repo!789");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "closes");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 789);
|
||||||
|
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_multiple_refs() {
|
||||||
|
let refs = parse_cross_refs("mentioned in merge request !123 and mentioned in issue #456");
|
||||||
|
assert_eq!(refs.len(), 2);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 123);
|
||||||
|
assert_eq!(refs[1].target_entity_type, "issue");
|
||||||
|
assert_eq!(refs[1].target_iid, 456);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_no_refs() {
|
||||||
|
let refs = parse_cross_refs("Updated the description");
|
||||||
|
assert!(refs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_non_english_note() {
|
||||||
|
let refs = parse_cross_refs("a ajout\u{00e9} l'\u{00e9}tiquette ~bug");
|
||||||
|
assert!(refs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_multi_level_group_path() {
|
||||||
|
let refs = parse_cross_refs("mentioned in issue top/sub/project#123");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("top/sub/project")
|
||||||
|
);
|
||||||
|
assert_eq!(refs[0].target_iid, 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_deeply_nested_group_path() {
|
||||||
|
let refs = parse_cross_refs("mentioned in merge request a/b/c/d/e!42");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_project_path.as_deref(), Some("a/b/c/d/e"));
|
||||||
|
assert_eq!(refs[0].target_iid, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hyphenated_project_path() {
|
||||||
|
let refs = parse_cross_refs("mentioned in issue my-group/my-project#99");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("my-group/my-project")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dotted_project_path() {
|
||||||
|
let refs = parse_cross_refs("mentioned in issue visiostack.io/backend#123");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("visiostack.io/backend")
|
||||||
|
);
|
||||||
|
assert_eq!(refs[0].target_iid, 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dotted_nested_project_path() {
|
||||||
|
let refs = parse_cross_refs("closed by merge request my.org/sub.group/my.project!42");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("my.org/sub.group/my.project")
|
||||||
|
);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare-sigil fallback (no "issue"/"merge request" word) still works
|
||||||
|
#[test]
|
||||||
|
fn test_parse_bare_sigil_fallback() {
|
||||||
|
let refs = parse_cross_refs("mentioned in #123");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_iid, 123);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "issue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_bare_sigil_closed_by() {
|
||||||
|
let refs = parse_cross_refs("closed by !567");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].reference_type, "closes");
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 567);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mixed_mentioned_and_closed() {
|
||||||
|
let refs = parse_cross_refs("mentioned in merge request !10 and closed by merge request !20");
|
||||||
|
assert_eq!(refs.len(), 2);
|
||||||
|
assert_eq!(refs[0].reference_type, "mentioned");
|
||||||
|
assert_eq!(refs[0].target_iid, 10);
|
||||||
|
assert_eq!(refs[1].reference_type, "closes");
|
||||||
|
assert_eq!(refs[1].target_iid, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse_url_refs ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_same_project_issue() {
|
||||||
|
let refs = parse_url_refs(
|
||||||
|
"See https://gitlab.visiostack.com/vs/typescript-code/-/issues/3537 for details",
|
||||||
|
);
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "issue");
|
||||||
|
assert_eq!(refs[0].target_iid, 3537);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("vs/typescript-code")
|
||||||
|
);
|
||||||
|
assert_eq!(refs[0].reference_type, "mentioned");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_merge_request() {
|
||||||
|
let refs =
|
||||||
|
parse_url_refs("https://gitlab.visiostack.com/vs/typescript-code/-/merge_requests/3548");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 3548);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("vs/typescript-code")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_cross_project() {
|
||||||
|
let refs = parse_url_refs(
|
||||||
|
"Related: https://gitlab.visiostack.com/vs/python-code/-/merge_requests/5203",
|
||||||
|
);
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 5203);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("vs/python-code")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_with_anchor() {
|
||||||
|
let refs =
|
||||||
|
parse_url_refs("https://gitlab.visiostack.com/vs/typescript-code/-/issues/123#note_456");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "issue");
|
||||||
|
assert_eq!(refs[0].target_iid, 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_markdown_link() {
|
||||||
|
let refs = parse_url_refs(
|
||||||
|
"Check [this MR](https://gitlab.visiostack.com/vs/typescript-code/-/merge_requests/100) for context",
|
||||||
|
);
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[0].target_iid, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_multiple_urls() {
|
||||||
|
let body =
|
||||||
|
"See https://gitlab.com/a/b/-/issues/1 and https://gitlab.com/a/b/-/merge_requests/2";
|
||||||
|
let refs = parse_url_refs(body);
|
||||||
|
assert_eq!(refs.len(), 2);
|
||||||
|
assert_eq!(refs[0].target_entity_type, "issue");
|
||||||
|
assert_eq!(refs[0].target_iid, 1);
|
||||||
|
assert_eq!(refs[1].target_entity_type, "merge_request");
|
||||||
|
assert_eq!(refs[1].target_iid, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_deduplicates() {
|
||||||
|
let body = "See https://gitlab.com/a/b/-/issues/1 and again https://gitlab.com/a/b/-/issues/1";
|
||||||
|
let refs = parse_url_refs(body);
|
||||||
|
assert_eq!(
|
||||||
|
refs.len(),
|
||||||
|
1,
|
||||||
|
"Duplicate URLs in same body should be deduplicated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_non_gitlab_urls_ignored() {
|
||||||
|
let refs = parse_url_refs(
|
||||||
|
"Check https://google.com/search?q=test and https://github.com/org/repo/issues/1",
|
||||||
|
);
|
||||||
|
assert!(refs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_ref_deeply_nested_project() {
|
||||||
|
let refs = parse_url_refs("https://gitlab.com/org/sub/deep/project/-/issues/42");
|
||||||
|
assert_eq!(refs.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
refs[0].target_project_path.as_deref(),
|
||||||
|
Some("org/sub/deep/project")
|
||||||
|
);
|
||||||
|
assert_eq!(refs[0].target_iid, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Integration tests: system notes (updated for real format) ---
|
||||||
|
|
||||||
|
fn setup_test_db() -> Connection {
|
||||||
|
use crate::core::db::{create_connection, run_migrations};
|
||||||
|
|
||||||
|
let conn = create_connection(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_test_data(conn: &Connection) -> i64 {
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'group/test-project', 'https://gitlab.com/group/test-project', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 123, 'Test Issue', 'opened', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (11, 1001, 1, 456, 'Another Issue', 'opened', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 789, 'Test MR', 'opened', 'feat', 'main', 'dev', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||||
|
VALUES (30, 'disc-aaa', 1, 10, 'Issue', ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, last_seen_at)
|
||||||
|
VALUES (31, 'disc-bbb', 1, 20, 'MergeRequest', ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// System note: real GitLab format "mentioned in merge request !789"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (40, 4000, 30, 1, 1, 'mentioned in merge request !789', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// System note: real GitLab format "mentioned in issue #456"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (41, 4001, 31, 1, 1, 'mentioned in issue #456', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// User note (is_system=0) — should NOT be processed by system note extractor
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (42, 4002, 30, 1, 0, 'mentioned in merge request !999', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// System note with no cross-ref pattern
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (43, 4003, 30, 1, 1, 'added label ~bug', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// System note: cross-project ref
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (44, 4004, 30, 1, 1, 'mentioned in issue other/project#999', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_system_notes_integration() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_test_data(&conn);
|
||||||
|
|
||||||
|
let result = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.inserted, 2, "Two same-project refs should resolve");
|
||||||
|
assert_eq!(
|
||||||
|
result.skipped_unresolvable, 1,
|
||||||
|
"One cross-project ref should be unresolvable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.parse_failures, 1,
|
||||||
|
"One system note has no cross-ref pattern"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ref_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1 AND source_method = 'note_parse'",
|
||||||
|
[project_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ref_count, 3, "Should have 3 entity_references rows total");
|
||||||
|
|
||||||
|
let unresolved_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL AND source_method = 'note_parse'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
unresolved_count, 1,
|
||||||
|
"Should have 1 unresolved cross-project ref"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (path, iid): (String, i64) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, "other/project");
|
||||||
|
assert_eq!(iid, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_idempotent() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let project_id = seed_test_data(&conn);
|
||||||
|
|
||||||
|
let result1 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||||
|
let result2 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result2.inserted, 0);
|
||||||
|
assert_eq!(result2.skipped_unresolvable, 0);
|
||||||
|
|
||||||
|
let total: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
total,
|
||||||
|
(result1.inserted + result1.skipped_unresolvable) as i64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_empty_project() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let result = extract_refs_from_system_notes(&conn, 999).unwrap();
|
||||||
|
assert_eq!(result.inserted, 0);
|
||||||
|
assert_eq!(result.skipped_unresolvable, 0);
|
||||||
|
assert_eq!(result.parse_failures, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Integration tests: description extraction ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_descriptions_issue() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/typescript-code', 'https://gitlab.com/vs/typescript-code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Issue with MR reference in description
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, description, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 3537, 'Test Issue', 'opened',
|
||||||
|
'Related to https://gitlab.com/vs/typescript-code/-/merge_requests/3548',
|
||||||
|
?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The target MR so it resolves
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 3548, 'Fix MR', 'merged', 'fix', 'main', 'dev', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = extract_refs_from_descriptions(&conn, 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.inserted, 1, "Should insert 1 description ref");
|
||||||
|
assert_eq!(result.skipped_unresolvable, 0);
|
||||||
|
|
||||||
|
let method: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT source_method FROM entity_references WHERE project_id = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(method, "description_parse");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_descriptions_mr() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/typescript-code', 'https://gitlab.com/vs/typescript-code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 100, 'Target Issue', 'opened', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, description, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 200, 'Fixing MR', 'merged', 'fix', 'main', 'dev',
|
||||||
|
'Fixes https://gitlab.com/vs/typescript-code/-/issues/100',
|
||||||
|
?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = extract_refs_from_descriptions(&conn, 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.inserted, 1);
|
||||||
|
|
||||||
|
let (src_type, tgt_type): (String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT source_entity_type, target_entity_type FROM entity_references WHERE project_id = 1",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(src_type, "merge_request");
|
||||||
|
assert_eq!(tgt_type, "issue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_descriptions_idempotent() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/code', 'https://gitlab.com/vs/code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, description, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 1, 'Issue', 'opened',
|
||||||
|
'See https://gitlab.com/vs/code/-/merge_requests/2', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 2, 'MR', 'opened', 'x', 'main', 'dev', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let r1 = extract_refs_from_descriptions(&conn, 1).unwrap();
|
||||||
|
assert_eq!(r1.inserted, 1);
|
||||||
|
|
||||||
|
let r2 = extract_refs_from_descriptions(&conn, 1).unwrap();
|
||||||
|
assert_eq!(r2.inserted, 0, "Second run should insert 0 (idempotent)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_descriptions_cross_project_unresolved() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/typescript-code', 'https://gitlab.com/vs/typescript-code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, description, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 1, 'Issue', 'opened',
|
||||||
|
'See https://gitlab.com/vs/other-project/-/merge_requests/99', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = extract_refs_from_descriptions(&conn, 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.inserted, 0);
|
||||||
|
assert_eq!(
|
||||||
|
result.skipped_unresolvable, 1,
|
||||||
|
"Cross-project ref with no matching project should be unresolvable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (path, iid): (String, i64) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, "vs/other-project");
|
||||||
|
assert_eq!(iid, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Integration tests: user note extraction ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_user_notes_with_url() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/code', 'https://gitlab.com/vs/code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 50, 'Source Issue', 'opened', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 60, 'Target MR', 'opened', 'x', 'main', 'dev', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||||
|
VALUES (30, 'disc-user', 1, 10, 'Issue', ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// User note with a URL
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (40, 4000, 30, 1, 0,
|
||||||
|
'This is related to https://gitlab.com/vs/code/-/merge_requests/60',
|
||||||
|
?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = extract_refs_from_user_notes(&conn, 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.inserted, 1);
|
||||||
|
|
||||||
|
let method: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT source_method FROM entity_references WHERE project_id = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(method, "note_parse");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_user_notes_no_system_note_patterns() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/code', 'https://gitlab.com/vs/code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 50, 'Source', 'opened', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 999, 'Target', 'opened', 'x', 'main', 'dev', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||||
|
VALUES (30, 'disc-x', 1, 10, 'Issue', ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// User note with system-note-like text but no URL — should NOT extract
|
||||||
|
// (user notes only use URL parsing, not system note pattern matching)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (40, 4000, 30, 1, 0, 'mentioned in merge request !999', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = extract_refs_from_user_notes(&conn, 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.inserted, 0,
|
||||||
|
"User notes should only parse URLs, not system note patterns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_refs_from_user_notes_idempotent() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let now = now_ms();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (1, 100, 'vs/code', 'https://gitlab.com/vs/code', ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (10, 1000, 1, 1, 'Src', 'opened', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (20, 2000, 1, 2, 'Tgt', 'opened', 'x', 'main', 'dev', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||||
|
VALUES (30, 'disc-y', 1, 10, 'Issue', ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES (40, 4000, 30, 1, 0,
|
||||||
|
'See https://gitlab.com/vs/code/-/merge_requests/2', ?1, ?1, ?1)",
|
||||||
|
[now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let r1 = extract_refs_from_user_notes(&conn, 1).unwrap();
|
||||||
|
assert_eq!(r1.inserted, 1);
|
||||||
|
|
||||||
|
let r2 = extract_refs_from_user_notes(&conn, 1).unwrap();
|
||||||
|
assert_eq!(r2.inserted, 0, "Second extraction should be idempotent");
|
||||||
|
}
|
||||||
244
src/core/path_resolver.rs
Normal file
244
src/core/path_resolver.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use super::error::{LoreError, Result};
|
||||||
|
|
||||||
|
// ─── SQL Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Escape LIKE metacharacters (`%`, `_`, `\`).
|
||||||
|
/// All queries using this must include `ESCAPE '\'`.
|
||||||
|
pub fn escape_like(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize user-supplied repo paths to match stored DiffNote / file-change paths.
|
||||||
|
/// - trims whitespace
|
||||||
|
/// - strips leading "./" and "/" (repo-relative paths)
|
||||||
|
/// - converts '\' to '/' when no '/' present (Windows paste)
|
||||||
|
/// - collapses repeated "//"
|
||||||
|
pub fn normalize_repo_path(input: &str) -> String {
|
||||||
|
let mut s = input.trim().to_string();
|
||||||
|
// Windows backslash normalization (only when no forward slashes present)
|
||||||
|
if s.contains('\\') && !s.contains('/') {
|
||||||
|
s = s.replace('\\', "/");
|
||||||
|
}
|
||||||
|
// Strip leading ./
|
||||||
|
while s.starts_with("./") {
|
||||||
|
s = s[2..].to_string();
|
||||||
|
}
|
||||||
|
// Strip leading /
|
||||||
|
s = s.trim_start_matches('/').to_string();
|
||||||
|
// Collapse repeated //
|
||||||
|
while s.contains("//") {
|
||||||
|
s = s.replace("//", "/");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Path Query Resolution ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Describes how to match a user-supplied path in SQL.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PathQuery {
|
||||||
|
/// The parameter value to bind.
|
||||||
|
pub value: String,
|
||||||
|
/// If true: use `LIKE value ESCAPE '\'`. If false: use `= value`.
|
||||||
|
pub is_prefix: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a suffix probe against the DB.
|
||||||
|
pub enum SuffixResult {
|
||||||
|
/// Suffix probe was not attempted (conditions not met).
|
||||||
|
NotAttempted,
|
||||||
|
/// No paths matched the suffix.
|
||||||
|
NoMatch,
|
||||||
|
/// Exactly one distinct path matched — auto-resolve.
|
||||||
|
Unique(String),
|
||||||
|
/// Multiple distinct paths matched — user must disambiguate.
|
||||||
|
Ambiguous(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a path query from a user-supplied path, with project-scoped DB probes.
|
||||||
|
///
|
||||||
|
/// Resolution strategy (in priority order):
|
||||||
|
/// 1. Trailing `/` → directory prefix (LIKE `path/%`)
|
||||||
|
/// 2. Exact match probe against notes + `mr_file_changes` → exact (= `path`)
|
||||||
|
/// 3. Directory prefix probe → prefix (LIKE `path/%`)
|
||||||
|
/// 4. Suffix probe for bare filenames → auto-resolve or ambiguity error
|
||||||
|
/// 5. Heuristic fallback: `.` in last segment → file, else → directory prefix
|
||||||
|
pub fn build_path_query(
|
||||||
|
conn: &Connection,
|
||||||
|
path: &str,
|
||||||
|
project_id: Option<i64>,
|
||||||
|
) -> Result<PathQuery> {
|
||||||
|
let trimmed = path.trim_end_matches('/');
|
||||||
|
let last_segment = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||||
|
let is_root = !trimmed.contains('/');
|
||||||
|
let forced_dir = path.ends_with('/');
|
||||||
|
// Heuristic is now only a fallback; probes decide first when ambiguous.
|
||||||
|
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||||
|
|
||||||
|
// Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||||
|
// Checks both new_path and old_path to support querying renamed files.
|
||||||
|
let exact_exists = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created
|
||||||
|
WHERE note_type = 'DiffNote'
|
||||||
|
AND is_system = 0
|
||||||
|
AND (position_new_path = ?1 OR position_old_path = ?1)
|
||||||
|
AND (?2 IS NULL OR project_id = ?2)
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![trimmed, project_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
|| conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM mr_file_changes
|
||||||
|
WHERE (new_path = ?1 OR old_path = ?1)
|
||||||
|
AND (?2 IS NULL OR project_id = ?2)
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![trimmed, project_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
// Probe 2: directory prefix exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||||
|
let prefix_exists = if !forced_dir && !exact_exists {
|
||||||
|
let escaped = escape_like(trimmed);
|
||||||
|
let pat = format!("{escaped}/%");
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created
|
||||||
|
WHERE note_type = 'DiffNote'
|
||||||
|
AND is_system = 0
|
||||||
|
AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_old_path LIKE ?1 ESCAPE '\\')
|
||||||
|
AND (?2 IS NULL OR project_id = ?2)
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![pat, project_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
|| conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM mr_file_changes
|
||||||
|
WHERE (new_path LIKE ?1 ESCAPE '\\' OR old_path LIKE ?1 ESCAPE '\\')
|
||||||
|
AND (?2 IS NULL OR project_id = ?2)
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![pat, project_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probe 3: suffix match — user typed a bare filename or partial path that
|
||||||
|
// doesn't exist as-is. Search for full paths ending with /input (or equal to input).
|
||||||
|
// This handles "login.rs" matching "src/auth/login.rs".
|
||||||
|
let suffix_resolved = if !forced_dir && !exact_exists && !prefix_exists && looks_like_file {
|
||||||
|
suffix_probe(conn, trimmed, project_id)?
|
||||||
|
} else {
|
||||||
|
SuffixResult::NotAttempted
|
||||||
|
};
|
||||||
|
|
||||||
|
match suffix_resolved {
|
||||||
|
SuffixResult::Unique(full_path) => Ok(PathQuery {
|
||||||
|
value: full_path,
|
||||||
|
is_prefix: false,
|
||||||
|
}),
|
||||||
|
SuffixResult::Ambiguous(candidates) => {
|
||||||
|
let list = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!(" {p}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
Err(LoreError::Ambiguous(format!(
|
||||||
|
"'{trimmed}' matches multiple paths. Use the full path or -p to scope:\n{list}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
SuffixResult::NotAttempted | SuffixResult::NoMatch => {
|
||||||
|
// Original logic: exact > prefix > heuristic
|
||||||
|
let is_file = if forced_dir {
|
||||||
|
false
|
||||||
|
} else if exact_exists {
|
||||||
|
true
|
||||||
|
} else if prefix_exists {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
looks_like_file
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_file {
|
||||||
|
Ok(PathQuery {
|
||||||
|
value: trimmed.to_string(),
|
||||||
|
is_prefix: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let escaped = escape_like(trimmed);
|
||||||
|
Ok(PathQuery {
|
||||||
|
value: format!("{escaped}/%"),
|
||||||
|
is_prefix: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe both notes and mr_file_changes for paths ending with the given suffix.
|
||||||
|
/// Searches both new_path and old_path columns to support renamed file resolution.
|
||||||
|
/// Returns up to 11 distinct candidates (enough to detect ambiguity + show a useful list).
|
||||||
|
pub fn suffix_probe(
|
||||||
|
conn: &Connection,
|
||||||
|
suffix: &str,
|
||||||
|
project_id: Option<i64>,
|
||||||
|
) -> Result<SuffixResult> {
|
||||||
|
let escaped = escape_like(suffix);
|
||||||
|
let suffix_pat = format!("%/{escaped}");
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare_cached(
|
||||||
|
"SELECT DISTINCT full_path FROM (
|
||||||
|
SELECT position_new_path AS full_path
|
||||||
|
FROM notes INDEXED BY idx_notes_diffnote_path_created
|
||||||
|
WHERE note_type = 'DiffNote'
|
||||||
|
AND is_system = 0
|
||||||
|
AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_new_path = ?2)
|
||||||
|
AND (?3 IS NULL OR project_id = ?3)
|
||||||
|
UNION
|
||||||
|
SELECT new_path AS full_path FROM mr_file_changes
|
||||||
|
WHERE (new_path LIKE ?1 ESCAPE '\\' OR new_path = ?2)
|
||||||
|
AND (?3 IS NULL OR project_id = ?3)
|
||||||
|
UNION
|
||||||
|
SELECT position_old_path AS full_path FROM notes
|
||||||
|
WHERE note_type = 'DiffNote'
|
||||||
|
AND is_system = 0
|
||||||
|
AND position_old_path IS NOT NULL
|
||||||
|
AND (position_old_path LIKE ?1 ESCAPE '\\' OR position_old_path = ?2)
|
||||||
|
AND (?3 IS NULL OR project_id = ?3)
|
||||||
|
UNION
|
||||||
|
SELECT old_path AS full_path FROM mr_file_changes
|
||||||
|
WHERE old_path IS NOT NULL
|
||||||
|
AND (old_path LIKE ?1 ESCAPE '\\' OR old_path = ?2)
|
||||||
|
AND (?3 IS NULL OR project_id = ?3)
|
||||||
|
)
|
||||||
|
ORDER BY full_path
|
||||||
|
LIMIT 11",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let candidates: Vec<String> = stmt
|
||||||
|
.query_map(rusqlite::params![suffix_pat, suffix, project_id], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
match candidates.len() {
|
||||||
|
0 => Ok(SuffixResult::NoMatch),
|
||||||
|
1 => Ok(SuffixResult::Unique(candidates.into_iter().next().unwrap())),
|
||||||
|
_ => Ok(SuffixResult::Ambiguous(candidates)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "path_resolver_tests.rs"]
|
||||||
|
mod tests;
|
||||||
290
src/core/path_resolver_tests.rs
Normal file
290
src/core/path_resolver_tests.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::core::db::{create_connection, run_migrations};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn setup_test_db() -> Connection {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_project(conn: &Connection, id: i64) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||||
|
VALUES (?1, ?1, 'group/repo', 'https://gl.example.com/group/repo', 1000, 2000)",
|
||||||
|
rusqlite::params![id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_mr(conn: &Connection, mr_id: i64, project_id: i64) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \
|
||||||
|
created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||||
|
VALUES (?1, ?1, ?1, ?2, 'MR', 'merged', 1000, 2000, 2000, 'feat', 'main')",
|
||||||
|
rusqlite::params![mr_id, project_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_file_change(conn: &Connection, mr_id: i64, project_id: i64, path: &str) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type)
|
||||||
|
VALUES (?1, ?2, ?3, 'modified')",
|
||||||
|
rusqlite::params![mr_id, project_id, path],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_diffnote(conn: &Connection, id: i64, project_id: i64, path: &str) {
|
||||||
|
// Need a discussion first (MergeRequest type, linked to mr_id=1)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO discussions (id, gitlab_discussion_id, project_id, \
|
||||||
|
merge_request_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at)
|
||||||
|
VALUES (?1, ?2, ?3, 1, 'MergeRequest', 1, 0, 2000, 2000)",
|
||||||
|
rusqlite::params![id, format!("disc-{id}"), project_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, \
|
||||||
|
author_username, body, created_at, updated_at, last_seen_at, position_new_path)
|
||||||
|
VALUES (?1, ?1, ?1, ?2, 'DiffNote', 0, 'user', 'note', 1000, 2000, 2000, ?3)",
|
||||||
|
rusqlite::params![id, project_id, path],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── escape_like ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_escape_like() {
|
||||||
|
assert_eq!(escape_like("normal/path"), "normal/path");
|
||||||
|
assert_eq!(escape_like("has_underscore"), "has\\_underscore");
|
||||||
|
assert_eq!(escape_like("has%percent"), "has\\%percent");
|
||||||
|
assert_eq!(escape_like("has\\backslash"), "has\\\\backslash");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── normalize_repo_path ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_repo_path() {
|
||||||
|
assert_eq!(normalize_repo_path("./src/foo/"), "src/foo/");
|
||||||
|
assert_eq!(normalize_repo_path("/src/foo/"), "src/foo/");
|
||||||
|
assert_eq!(normalize_repo_path("././src/foo"), "src/foo");
|
||||||
|
assert_eq!(normalize_repo_path("src\\foo\\bar.rs"), "src/foo/bar.rs");
|
||||||
|
assert_eq!(normalize_repo_path("src/foo\\bar"), "src/foo\\bar");
|
||||||
|
assert_eq!(normalize_repo_path("src//foo//bar/"), "src/foo/bar/");
|
||||||
|
assert_eq!(normalize_repo_path(" src/foo/ "), "src/foo/");
|
||||||
|
assert_eq!(normalize_repo_path("src/foo/bar.rs"), "src/foo/bar.rs");
|
||||||
|
assert_eq!(normalize_repo_path(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── build_path_query heuristics (no DB data) ──────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trailing_slash_is_prefix() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "src/auth/", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/auth/%");
|
||||||
|
assert!(pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_dot_in_last_segment_is_prefix() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "src/auth", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/auth/%");
|
||||||
|
assert!(pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_extension_is_exact() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "src/auth/login.rs", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/auth/login.rs");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_root_file_is_exact() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "README.md", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "README.md");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dotless_root_file_is_exact() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "Makefile", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "Makefile");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "LICENSE", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "LICENSE");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metacharacters_escaped_in_prefix() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "src/test_files/", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/test\\_files/%");
|
||||||
|
assert!(pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exact_value_not_escaped() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let pq = build_path_query(&conn, "README_with_underscore.md", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "README_with_underscore.md");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── build_path_query DB probes ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_probe_detects_dotless_file() {
|
||||||
|
// "src/Dockerfile" has no dot in last segment -> normally prefix.
|
||||||
|
// DB probe detects it's actually a file.
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_diffnote(&conn, 1, 1, "src/Dockerfile");
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/Dockerfile");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
|
||||||
|
// Without DB data -> falls through to prefix
|
||||||
|
let empty = setup_test_db();
|
||||||
|
let pq2 = build_path_query(&empty, "src/Dockerfile", None).unwrap();
|
||||||
|
assert!(pq2.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_probe_via_file_changes() {
|
||||||
|
// Exact match via mr_file_changes even without notes
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_file_change(&conn, 1, 1, "src/Dockerfile");
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/Dockerfile");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_probe_project_scoped() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_project(&conn, 2);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_diffnote(&conn, 1, 1, "infra/Makefile");
|
||||||
|
|
||||||
|
// Unscoped: finds it
|
||||||
|
assert!(
|
||||||
|
!build_path_query(&conn, "infra/Makefile", None)
|
||||||
|
.unwrap()
|
||||||
|
.is_prefix
|
||||||
|
);
|
||||||
|
// Scoped to project 1: finds it
|
||||||
|
assert!(
|
||||||
|
!build_path_query(&conn, "infra/Makefile", Some(1))
|
||||||
|
.unwrap()
|
||||||
|
.is_prefix
|
||||||
|
);
|
||||||
|
// Scoped to project 2: no data -> prefix
|
||||||
|
assert!(
|
||||||
|
build_path_query(&conn, "infra/Makefile", Some(2))
|
||||||
|
.unwrap()
|
||||||
|
.is_prefix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── suffix resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suffix_resolves_bare_filename() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_file_change(&conn, 1, 1, "src/auth/login.rs");
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "login.rs", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/auth/login.rs");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suffix_resolves_partial_path() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_file_change(&conn, 1, 1, "src/auth/login.rs");
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "auth/login.rs", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/auth/login.rs");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suffix_ambiguous_returns_error() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_file_change(&conn, 1, 1, "src/auth/utils.rs");
|
||||||
|
seed_file_change(&conn, 1, 1, "src/db/utils.rs");
|
||||||
|
|
||||||
|
let err = build_path_query(&conn, "utils.rs", None).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("src/auth/utils.rs"), "candidates: {msg}");
|
||||||
|
assert!(msg.contains("src/db/utils.rs"), "candidates: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suffix_scoped_to_project() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_project(&conn, 2);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_mr(&conn, 2, 2);
|
||||||
|
seed_file_change(&conn, 1, 1, "src/utils.rs");
|
||||||
|
seed_file_change(&conn, 2, 2, "lib/utils.rs");
|
||||||
|
|
||||||
|
// Unscoped: ambiguous
|
||||||
|
assert!(build_path_query(&conn, "utils.rs", None).is_err());
|
||||||
|
|
||||||
|
// Scoped to project 1: resolves
|
||||||
|
let pq = build_path_query(&conn, "utils.rs", Some(1)).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/utils.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suffix_deduplicates_across_sources() {
|
||||||
|
// Same path in notes AND file_changes -> single match, not ambiguous
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_file_change(&conn, 1, 1, "src/auth/login.rs");
|
||||||
|
seed_diffnote(&conn, 1, 1, "src/auth/login.rs");
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "login.rs", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/auth/login.rs");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exact_match_preferred_over_suffix() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
seed_project(&conn, 1);
|
||||||
|
seed_mr(&conn, 1, 1);
|
||||||
|
seed_file_change(&conn, 1, 1, "README.md");
|
||||||
|
seed_file_change(&conn, 1, 1, "docs/README.md");
|
||||||
|
|
||||||
|
// "README.md" exists as exact match -> no ambiguity
|
||||||
|
let pq = build_path_query(&conn, "README.md", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "README.md");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
@@ -95,110 +95,5 @@ pub fn read_payload(conn: &Connection, id: i64) -> Result<Option<serde_json::Val
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "payloads_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use crate::core::db::create_connection;
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
fn setup_test_db() -> Connection {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let db_path = dir.path().join("test.db");
|
|
||||||
let conn = create_connection(&db_path).unwrap();
|
|
||||||
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE raw_payloads (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
project_id INTEGER,
|
|
||||||
resource_type TEXT NOT NULL,
|
|
||||||
gitlab_id TEXT NOT NULL,
|
|
||||||
fetched_at INTEGER NOT NULL,
|
|
||||||
content_encoding TEXT NOT NULL DEFAULT 'identity',
|
|
||||||
payload_hash TEXT NOT NULL,
|
|
||||||
payload BLOB NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX uq_raw_payloads_dedupe
|
|
||||||
ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash);",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_store_and_read_payload() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let payload = serde_json::json!({"title": "Test Issue", "id": 123});
|
|
||||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
|
||||||
|
|
||||||
let id = store_payload(
|
|
||||||
&conn,
|
|
||||||
StorePayloadOptions {
|
|
||||||
project_id: Some(1),
|
|
||||||
resource_type: "issue",
|
|
||||||
gitlab_id: "123",
|
|
||||||
json_bytes: &json_bytes,
|
|
||||||
compress: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = read_payload(&conn, id).unwrap().unwrap();
|
|
||||||
assert_eq!(result["title"], "Test Issue");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_compression_roundtrip() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let payload = serde_json::json!({"data": "x".repeat(1000)});
|
|
||||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
|
||||||
|
|
||||||
let id = store_payload(
|
|
||||||
&conn,
|
|
||||||
StorePayloadOptions {
|
|
||||||
project_id: Some(1),
|
|
||||||
resource_type: "issue",
|
|
||||||
gitlab_id: "456",
|
|
||||||
json_bytes: &json_bytes,
|
|
||||||
compress: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = read_payload(&conn, id).unwrap().unwrap();
|
|
||||||
assert_eq!(result["data"], "x".repeat(1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_deduplication() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
let payload = serde_json::json!({"id": 789});
|
|
||||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
|
||||||
|
|
||||||
let id1 = store_payload(
|
|
||||||
&conn,
|
|
||||||
StorePayloadOptions {
|
|
||||||
project_id: Some(1),
|
|
||||||
resource_type: "issue",
|
|
||||||
gitlab_id: "789",
|
|
||||||
json_bytes: &json_bytes,
|
|
||||||
compress: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let id2 = store_payload(
|
|
||||||
&conn,
|
|
||||||
StorePayloadOptions {
|
|
||||||
project_id: Some(1),
|
|
||||||
resource_type: "issue",
|
|
||||||
gitlab_id: "789",
|
|
||||||
json_bytes: &json_bytes,
|
|
||||||
compress: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(id1, id2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
105
src/core/payloads_tests.rs
Normal file
105
src/core/payloads_tests.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::core::db::create_connection;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn setup_test_db() -> Connection {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("test.db");
|
||||||
|
let conn = create_connection(&db_path).unwrap();
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE raw_payloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
project_id INTEGER,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
gitlab_id TEXT NOT NULL,
|
||||||
|
fetched_at INTEGER NOT NULL,
|
||||||
|
content_encoding TEXT NOT NULL DEFAULT 'identity',
|
||||||
|
payload_hash TEXT NOT NULL,
|
||||||
|
payload BLOB NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX uq_raw_payloads_dedupe
|
||||||
|
ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_and_read_payload() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let payload = serde_json::json!({"title": "Test Issue", "id": 123});
|
||||||
|
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||||
|
|
||||||
|
let id = store_payload(
|
||||||
|
&conn,
|
||||||
|
StorePayloadOptions {
|
||||||
|
project_id: Some(1),
|
||||||
|
resource_type: "issue",
|
||||||
|
gitlab_id: "123",
|
||||||
|
json_bytes: &json_bytes,
|
||||||
|
compress: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = read_payload(&conn, id).unwrap().unwrap();
|
||||||
|
assert_eq!(result["title"], "Test Issue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compression_roundtrip() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let payload = serde_json::json!({"data": "x".repeat(1000)});
|
||||||
|
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||||
|
|
||||||
|
let id = store_payload(
|
||||||
|
&conn,
|
||||||
|
StorePayloadOptions {
|
||||||
|
project_id: Some(1),
|
||||||
|
resource_type: "issue",
|
||||||
|
gitlab_id: "456",
|
||||||
|
json_bytes: &json_bytes,
|
||||||
|
compress: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = read_payload(&conn, id).unwrap().unwrap();
|
||||||
|
assert_eq!(result["data"], "x".repeat(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deduplication() {
|
||||||
|
let conn = setup_test_db();
|
||||||
|
let payload = serde_json::json!({"id": 789});
|
||||||
|
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||||
|
|
||||||
|
let id1 = store_payload(
|
||||||
|
&conn,
|
||||||
|
StorePayloadOptions {
|
||||||
|
project_id: Some(1),
|
||||||
|
resource_type: "issue",
|
||||||
|
gitlab_id: "789",
|
||||||
|
json_bytes: &json_bytes,
|
||||||
|
compress: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let id2 = store_payload(
|
||||||
|
&conn,
|
||||||
|
StorePayloadOptions {
|
||||||
|
project_id: Some(1),
|
||||||
|
resource_type: "issue",
|
||||||
|
gitlab_id: "789",
|
||||||
|
json_bytes: &json_bytes,
|
||||||
|
compress: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(id1, id2);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use super::error::{LoreError, Result};
|
use super::error::{LoreError, Result};
|
||||||
|
use super::path_resolver::escape_like;
|
||||||
|
|
||||||
pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
||||||
let exact = conn.query_row(
|
let exact = conn.query_row(
|
||||||
@@ -21,13 +22,14 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
|||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let escaped = escape_like(project_str);
|
||||||
let mut suffix_stmt = conn.prepare(
|
let mut suffix_stmt = conn.prepare(
|
||||||
"SELECT id, path_with_namespace FROM projects
|
"SELECT id, path_with_namespace FROM projects
|
||||||
WHERE path_with_namespace LIKE '%/' || ?1
|
WHERE path_with_namespace LIKE '%/' || ?1 ESCAPE '\\'
|
||||||
OR path_with_namespace = ?1",
|
OR path_with_namespace = ?2",
|
||||||
)?;
|
)?;
|
||||||
let suffix_matches: Vec<(i64, String)> = suffix_stmt
|
let suffix_matches: Vec<(i64, String)> = suffix_stmt
|
||||||
.query_map(rusqlite::params![project_str], |row| {
|
.query_map(rusqlite::params![escaped, project_str], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
Ok((row.get(0)?, row.get(1)?))
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -52,10 +54,10 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
|||||||
|
|
||||||
let mut substr_stmt = conn.prepare(
|
let mut substr_stmt = conn.prepare(
|
||||||
"SELECT id, path_with_namespace FROM projects
|
"SELECT id, path_with_namespace FROM projects
|
||||||
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%'",
|
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%' ESCAPE '\\'",
|
||||||
)?;
|
)?;
|
||||||
let substr_matches: Vec<(i64, String)> = substr_stmt
|
let substr_matches: Vec<(i64, String)> = substr_stmt
|
||||||
.query_map(rusqlite::params![project_str], |row| {
|
.query_map(rusqlite::params![escaped], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
Ok((row.get(0)?, row.get(1)?))
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -103,142 +105,8 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escape LIKE metacharacters so `%` and `_` in user input are treated as
|
||||||
|
/// literals. All queries using this must include `ESCAPE '\'`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "project_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn setup_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
conn.execute_batch(
|
|
||||||
"
|
|
||||||
CREATE TABLE projects (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
|
||||||
path_with_namespace TEXT NOT NULL,
|
|
||||||
default_branch TEXT,
|
|
||||||
web_url TEXT,
|
|
||||||
created_at INTEGER,
|
|
||||||
updated_at INTEGER,
|
|
||||||
raw_payload_id INTEGER
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_projects_path ON projects(path_with_namespace);
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_project(conn: &Connection, id: i64, path: &str) {
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (?1, ?2, ?3)",
|
|
||||||
rusqlite::params![id, id * 100, path],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_exact_match() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "backend/auth-service");
|
|
||||||
let id = resolve_project(&conn, "backend/auth-service").unwrap();
|
|
||||||
assert_eq!(id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_case_insensitive() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "backend/auth-service");
|
|
||||||
let id = resolve_project(&conn, "Backend/Auth-Service").unwrap();
|
|
||||||
assert_eq!(id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_suffix_unambiguous() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "backend/auth-service");
|
|
||||||
insert_project(&conn, 2, "frontend/web-ui");
|
|
||||||
let id = resolve_project(&conn, "auth-service").unwrap();
|
|
||||||
assert_eq!(id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_suffix_ambiguous() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "backend/auth-service");
|
|
||||||
insert_project(&conn, 2, "frontend/auth-service");
|
|
||||||
let err = resolve_project(&conn, "auth-service").unwrap_err();
|
|
||||||
let msg = err.to_string();
|
|
||||||
assert!(
|
|
||||||
msg.contains("ambiguous"),
|
|
||||||
"Expected ambiguous error, got: {}",
|
|
||||||
msg
|
|
||||||
);
|
|
||||||
assert!(msg.contains("backend/auth-service"));
|
|
||||||
assert!(msg.contains("frontend/auth-service"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_substring_unambiguous() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "vs/python-code");
|
|
||||||
insert_project(&conn, 2, "vs/typescript-code");
|
|
||||||
let id = resolve_project(&conn, "typescript").unwrap();
|
|
||||||
assert_eq!(id, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_substring_case_insensitive() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "vs/python-code");
|
|
||||||
insert_project(&conn, 2, "vs/typescript-code");
|
|
||||||
let id = resolve_project(&conn, "TypeScript").unwrap();
|
|
||||||
assert_eq!(id, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_substring_ambiguous() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "vs/python-code");
|
|
||||||
insert_project(&conn, 2, "vs/typescript-code");
|
|
||||||
let err = resolve_project(&conn, "code").unwrap_err();
|
|
||||||
let msg = err.to_string();
|
|
||||||
assert!(
|
|
||||||
msg.contains("ambiguous"),
|
|
||||||
"Expected ambiguous error, got: {}",
|
|
||||||
msg
|
|
||||||
);
|
|
||||||
assert!(msg.contains("vs/python-code"));
|
|
||||||
assert!(msg.contains("vs/typescript-code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_suffix_preferred_over_substring() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "backend/auth-service");
|
|
||||||
insert_project(&conn, 2, "backend/auth-service-v2");
|
|
||||||
let id = resolve_project(&conn, "auth-service").unwrap();
|
|
||||||
assert_eq!(id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_match() {
|
|
||||||
let conn = setup_db();
|
|
||||||
insert_project(&conn, 1, "backend/auth-service");
|
|
||||||
let err = resolve_project(&conn, "nonexistent").unwrap_err();
|
|
||||||
let msg = err.to_string();
|
|
||||||
assert!(
|
|
||||||
msg.contains("not found"),
|
|
||||||
"Expected not found error, got: {}",
|
|
||||||
msg
|
|
||||||
);
|
|
||||||
assert!(msg.contains("backend/auth-service"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_projects() {
|
|
||||||
let conn = setup_db();
|
|
||||||
let err = resolve_project(&conn, "anything").unwrap_err();
|
|
||||||
let msg = err.to_string();
|
|
||||||
assert!(msg.contains("No projects have been synced"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user