feat(who): expand expert + overlap queries with mr_file_changes and mr_reviewers
Chain: bd-jec (config flag) -> bd-2yo (fetch MR diffs) -> bd-3qn6 (rewrite who queries) - Add fetch_mr_file_changes config option and --no-file-changes CLI flag - Add GitLab MR diffs API fetch pipeline with watermark-based sync - Create migration 020 for diffs_synced_for_updated_at watermark column - Rewrite query_expert() and query_overlap() to use 4-signal UNION ALL: DiffNote reviewers, DiffNote MR authors, file-change authors, file-change reviewers - Deduplicate across signal types via COUNT(DISTINCT CASE WHEN ... THEN mr_id END) - Add insert_file_change test helper, 8 new who tests, all 397 tests pass - Also includes: list performance migration 019, autocorrect module, README updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-1q8z
|
bd-3qn6
|
||||||
|
|||||||
194
README.md
194
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Gitlore
|
# Gitlore
|
||||||
|
|
||||||
Local GitLab data management with semantic search and temporal intelligence. Syncs issues, MRs, discussions, and notes from GitLab to a local SQLite database for fast, offline-capable querying, filtering, hybrid search, and chronological event reconstruction.
|
Local GitLab data management with semantic search, people intelligence, and temporal analysis. Syncs issues, MRs, discussions, and notes from GitLab to a local SQLite database for fast, offline-capable querying, filtering, hybrid search, chronological event reconstruction, and expert discovery.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ Local GitLab data management with semantic search and temporal intelligence. Syn
|
|||||||
- **Multi-project**: Track issues and MRs across multiple GitLab projects
|
- **Multi-project**: Track issues and MRs across multiple GitLab projects
|
||||||
- **Rich filtering**: Filter by state, author, assignee, labels, milestone, due date, draft status, reviewer, branches
|
- **Rich filtering**: Filter by state, author, assignee, labels, milestone, due date, draft status, reviewer, branches
|
||||||
- **Hybrid search**: Combines FTS5 lexical search with Ollama-powered vector embeddings via Reciprocal Rank Fusion
|
- **Hybrid search**: Combines FTS5 lexical search with Ollama-powered vector embeddings via Reciprocal Rank Fusion
|
||||||
|
- **People intelligence**: Expert discovery, workload analysis, review patterns, active discussions, and code ownership overlap
|
||||||
- **Timeline pipeline**: Reconstructs chronological event histories by combining search, graph traversal, and event aggregation across related entities
|
- **Timeline pipeline**: Reconstructs chronological event histories by combining search, graph traversal, and event aggregation across related entities
|
||||||
- **Git history linking**: Tracks merge and squash commit SHAs to connect MRs with git history
|
- **Git history linking**: Tracks merge and squash commit SHAs to connect MRs with git history
|
||||||
- **File change tracking**: Records which files each MR touches, enabling file-level history queries
|
- **File change tracking**: Records which files each MR touches, enabling file-level history queries
|
||||||
@@ -17,7 +18,7 @@ Local GitLab data management with semantic search and temporal intelligence. Syn
|
|||||||
- **Discussion threading**: Full support for issue and MR discussions including inline code review comments
|
- **Discussion threading**: Full support for issue and MR discussions including inline code review comments
|
||||||
- **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
|
||||||
- **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
|
||||||
- **Robot mode**: Machine-readable JSON output with structured errors and meaningful exit codes
|
- **Robot mode**: Machine-readable JSON output with structured errors, meaningful exit codes, and actionable recovery steps
|
||||||
- **Observability**: Verbosity controls, JSON log format, structured metrics, and stage timing
|
- **Observability**: Verbosity controls, JSON log format, structured metrics, and stage timing
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -60,6 +61,15 @@ lore mrs 456
|
|||||||
# Search across all indexed data
|
# Search across all indexed data
|
||||||
lore search "authentication bug"
|
lore search "authentication bug"
|
||||||
|
|
||||||
|
# Who knows about this code area?
|
||||||
|
lore who src/features/auth/
|
||||||
|
|
||||||
|
# What is @asmith working on?
|
||||||
|
lore who @asmith
|
||||||
|
|
||||||
|
# Timeline of events related to deployments
|
||||||
|
lore timeline "deployment"
|
||||||
|
|
||||||
# Robot mode (machine-readable JSON)
|
# Robot mode (machine-readable JSON)
|
||||||
lore -J issues -n 5 | jq .
|
lore -J issues -n 5 | jq .
|
||||||
```
|
```
|
||||||
@@ -256,8 +266,135 @@ lore search "deploy" --explain # Show ranking explanation per resu
|
|||||||
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).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
### `lore who`
|
||||||
|
|
||||||
|
People intelligence: discover experts, analyze workloads, review patterns, active discussions, and code overlap.
|
||||||
|
|
||||||
|
#### Expert Mode
|
||||||
|
|
||||||
|
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore who src/features/auth/ # Who knows about this directory?
|
||||||
|
lore who src/features/auth/login.ts # Who knows about this file?
|
||||||
|
lore who --path README.md # Root files need --path flag
|
||||||
|
lore who --path Makefile # Dotless root files too
|
||||||
|
lore who src/ --since 3m # Limit to recent 3 months
|
||||||
|
lore who src/ -p group/repo # Scope to project
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### Workload Mode
|
||||||
|
|
||||||
|
See what someone is currently working on.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore who @asmith # Full workload summary
|
||||||
|
lore who @asmith -p group/repo # Scoped to one project
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows: assigned open issues, authored MRs, MRs under review, and unresolved discussions.
|
||||||
|
|
||||||
|
#### Reviews Mode
|
||||||
|
|
||||||
|
Analyze someone's code review patterns by area.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore who @asmith --reviews # Review activity breakdown
|
||||||
|
lore who @asmith --reviews --since 3m # Recent review patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows: total DiffNotes, categorized by code area with percentage breakdown.
|
||||||
|
|
||||||
|
#### Active Mode
|
||||||
|
|
||||||
|
Surface unresolved discussions needing attention.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore who --active # Unresolved discussions (last 7 days)
|
||||||
|
lore who --active --since 30d # Wider time window
|
||||||
|
lore who --active -p group/repo # Scoped to project
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows: discussion threads with participants and last activity timestamps.
|
||||||
|
|
||||||
|
#### Overlap Mode
|
||||||
|
|
||||||
|
Find who else is touching a file or directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore who --overlap src/features/auth/ # Who else works here?
|
||||||
|
lore who --overlap src/lib.rs # Single file overlap
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows: users with touch counts (author vs. review), linked MR references. Default time window: 6 months.
|
||||||
|
|
||||||
|
#### Common Flags
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-p` / `--project` | Scope to a project (fuzzy match) |
|
||||||
|
| `--since` | Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. |
|
||||||
|
| `-n` / `--limit` | Max results per section (1-500, default 20) |
|
||||||
|
|
||||||
|
### `lore timeline`
|
||||||
|
|
||||||
|
Reconstruct a chronological timeline of events matching a keyword query. The pipeline discovers related entities through cross-reference graph traversal and assembles a unified, time-ordered event stream.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore timeline "deployment" # Events related to deployments
|
||||||
|
lore timeline "auth" -p group/repo # Scoped to a project
|
||||||
|
lore timeline "auth" --since 30d # Only recent events
|
||||||
|
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
|
||||||
|
lore timeline "migration" --expand-mentions # Follow 'mentioned' edges (high fan-out)
|
||||||
|
lore timeline "deploy" -n 50 # Limit event count
|
||||||
|
lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flags
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||||
|
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
|
||||||
|
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
|
||||||
|
| `--expand-mentions` | off | Also follow "mentioned" edges during expansion |
|
||||||
|
| `-n` / `--limit` | `100` | Maximum events to display |
|
||||||
|
| `--max-seeds` | `10` | Maximum seed entities from search |
|
||||||
|
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
|
||||||
|
| `--max-evidence` | `10` | Maximum evidence notes included |
|
||||||
|
|
||||||
|
#### Pipeline Stages
|
||||||
|
|
||||||
|
1. **SEED** -- Full-text search identifies the most relevant issues and MRs matching the query. Documents are ranked by BM25 relevance.
|
||||||
|
2. **HYDRATE** -- Evidence notes are extracted: the top FTS-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced.
|
||||||
|
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and optionally "mentioned" references up to the configured depth.
|
||||||
|
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, and evidence notes. Events are sorted chronologically with stable tiebreaking.
|
||||||
|
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).
|
||||||
|
|
||||||
|
#### Event Types
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `Created` | Entity creation |
|
||||||
|
| `StateChanged` | State transitions (opened, closed, reopened) |
|
||||||
|
| `LabelAdded` | Label applied to entity |
|
||||||
|
| `LabelRemoved` | Label removed from entity |
|
||||||
|
| `MilestoneSet` | Milestone assigned |
|
||||||
|
| `MilestoneRemoved` | Milestone removed |
|
||||||
|
| `Merged` | MR merged (deduplicated against state events) |
|
||||||
|
| `NoteEvidence` | Discussion note matched by FTS, with snippet |
|
||||||
|
| `CrossReferenced` | Reference to another entity |
|
||||||
|
|
||||||
|
#### Unresolved References
|
||||||
|
|
||||||
|
When graph expansion encounters cross-project references to entities not yet synced locally, these are collected as unresolved references in the output. This enables discovery of external dependencies and can inform future sync targets.
|
||||||
|
|
||||||
### `lore sync`
|
### `lore sync`
|
||||||
|
|
||||||
Run the full sync pipeline: ingest from GitLab, generate searchable documents, and compute embeddings.
|
Run the full sync pipeline: ingest from GitLab, generate searchable documents, and compute embeddings.
|
||||||
@@ -269,6 +406,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 --dry-run # Preview what would be synced
|
||||||
```
|
```
|
||||||
|
|
||||||
The sync command displays animated progress bars for each stage and outputs timing metrics on completion. In robot mode (`-J`), detailed stage timing is included in the JSON response.
|
The sync command displays animated progress bars for each stage and outputs timing metrics on completion. In robot mode (`-J`), detailed stage timing is included in the JSON response.
|
||||||
@@ -284,6 +422,7 @@ lore ingest mrs # MRs only
|
|||||||
lore ingest issues -p group/repo # Single project
|
lore ingest issues -p group/repo # Single project
|
||||||
lore ingest --force # Override stale lock
|
lore ingest --force # Override stale lock
|
||||||
lore ingest --full # Full re-sync (reset cursors)
|
lore ingest --full # Full re-sync (reset cursors)
|
||||||
|
lore ingest --dry-run # Preview what would change
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--full` flag resets sync cursors and discussion watermarks, then fetches all data from scratch. Useful when:
|
The `--full` flag resets sync cursors and discussion watermarks, then fetches all data from scratch. Useful when:
|
||||||
@@ -307,6 +446,7 @@ Generate vector embeddings for documents via Ollama. Requires Ollama running wit
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lore embed # Embed new/changed documents
|
lore embed # Embed new/changed documents
|
||||||
|
lore embed --full # Re-embed all documents (clears existing)
|
||||||
lore embed --retry-failed # Retry previously failed embeddings
|
lore embed --retry-failed # Retry previously failed embeddings
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -322,6 +462,9 @@ lore count discussions --for issue # Issue discussions only
|
|||||||
lore count discussions --for mr # MR discussions only
|
lore count discussions --for mr # MR discussions only
|
||||||
lore count notes # Total notes (system vs user breakdown)
|
lore count notes # Total notes (system vs user breakdown)
|
||||||
lore count notes --for issue # Issue notes only
|
lore count notes --for issue # Issue notes only
|
||||||
|
lore count events # Total resource events
|
||||||
|
lore count events --for issue # Issue events only
|
||||||
|
lore count events --for mr # MR events only
|
||||||
```
|
```
|
||||||
|
|
||||||
### `lore stats`
|
### `lore stats`
|
||||||
@@ -332,6 +475,7 @@ Show document and index statistics, with optional integrity checks.
|
|||||||
lore stats # Document and index statistics
|
lore stats # Document and index statistics
|
||||||
lore stats --check # Run integrity checks
|
lore stats --check # Run integrity checks
|
||||||
lore stats --check --repair # Repair integrity issues
|
lore stats --check --repair # Repair integrity issues
|
||||||
|
lore stats --dry-run # Preview repairs without saving
|
||||||
```
|
```
|
||||||
|
|
||||||
### `lore status`
|
### `lore status`
|
||||||
@@ -357,6 +501,14 @@ lore init --force # Overwrite existing config
|
|||||||
lore init --non-interactive # Fail if prompts needed
|
lore init --non-interactive # Fail if prompts needed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In robot mode, `init` supports non-interactive setup via flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lore -J init --gitlab-url https://gitlab.com \
|
||||||
|
--token-env-var GITLAB_TOKEN \
|
||||||
|
--projects "group/project,other/project"
|
||||||
|
```
|
||||||
|
|
||||||
### `lore auth`
|
### `lore auth`
|
||||||
|
|
||||||
Verify GitLab authentication is working.
|
Verify GitLab authentication is working.
|
||||||
@@ -392,7 +544,7 @@ lore migrate
|
|||||||
|
|
||||||
### `lore health`
|
### `lore health`
|
||||||
|
|
||||||
Quick pre-flight check for config, database, and schema version. Exits 0 if healthy, 1 if unhealthy.
|
Quick pre-flight check for config, database, and schema version. Exits 0 if healthy, 19 if unhealthy.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lore health
|
lore health
|
||||||
@@ -591,42 +743,6 @@ Data is stored in SQLite with WAL mode and foreign keys enabled. Main tables:
|
|||||||
|
|
||||||
The database is stored at `~/.local/share/lore/lore.db` by default (XDG compliant).
|
The database is stored at `~/.local/share/lore/lore.db` by default (XDG compliant).
|
||||||
|
|
||||||
## Timeline Pipeline
|
|
||||||
|
|
||||||
The timeline pipeline reconstructs chronological event histories for GitLab entities by combining full-text search, cross-reference graph traversal, and resource event aggregation. Given a search query, it identifies relevant issues and MRs, discovers related entities through their reference graph, and assembles a unified, time-ordered event stream.
|
|
||||||
|
|
||||||
### Stages
|
|
||||||
|
|
||||||
The pipeline executes in five stages:
|
|
||||||
|
|
||||||
1. **SEED** -- Full-text search identifies the most relevant issues and MRs matching the query. Documents (issue bodies, MR descriptions, discussion notes) are ranked by BM25 relevance.
|
|
||||||
|
|
||||||
2. **HYDRATE** -- Evidence notes are extracted from the seed results: the top FTS-matched discussion notes with 200-character snippets that explain *why* each entity was surfaced.
|
|
||||||
|
|
||||||
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities. Starting from seed entities, the pipeline follows "closes", "related", and optionally "mentioned" references up to a configurable depth, tracking provenance (which entity referenced which, via what method).
|
|
||||||
|
|
||||||
4. **COLLECT** -- Events are gathered for all discovered entities (seeds + expanded). Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, and evidence notes. Events are sorted chronologically with stable tiebreaking (timestamp, then entity ID, then event type).
|
|
||||||
|
|
||||||
5. **RENDER** -- Events are formatted for output as human-readable text or structured JSON.
|
|
||||||
|
|
||||||
### Event Types
|
|
||||||
|
|
||||||
| Event | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `Created` | Entity creation |
|
|
||||||
| `StateChanged` | State transitions (opened, closed, reopened) |
|
|
||||||
| `LabelAdded` | Label applied to entity |
|
|
||||||
| `LabelRemoved` | Label removed from entity |
|
|
||||||
| `MilestoneSet` | Milestone assigned |
|
|
||||||
| `MilestoneRemoved` | Milestone removed |
|
|
||||||
| `Merged` | MR merged (deduplicated against state events) |
|
|
||||||
| `NoteEvidence` | Discussion note matched by FTS, with snippet |
|
|
||||||
| `CrossReferenced` | Reference to another entity |
|
|
||||||
|
|
||||||
### Unresolved References
|
|
||||||
|
|
||||||
When the graph expansion encounters cross-project references to entities not yet synced locally, these are collected as unresolved references in the pipeline output. This enables discovery of external dependencies and can inform future sync targets.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
13
migrations/019_list_performance.sql
Normal file
13
migrations/019_list_performance.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Standalone updated_at DESC indexes for ORDER BY without temp B-tree sort.
|
||||||
|
-- The existing composite indexes (project_id, updated_at) only help when
|
||||||
|
-- filtering by project first.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_updated_at_desc
|
||||||
|
ON issues(updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mrs_updated_at_desc
|
||||||
|
ON merge_requests(updated_at DESC);
|
||||||
|
|
||||||
|
-- Covering index for correlated subquery: unresolved discussion count per issue.
|
||||||
|
-- MRs already have idx_discussions_mr_resolved (migration 006).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussions_issue_resolved
|
||||||
|
ON discussions(issue_id, resolvable, resolved);
|
||||||
7
migrations/020_mr_diffs_watermark.sql
Normal file
7
migrations/020_mr_diffs_watermark.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Migration 020: Watermark column for MR diffs sync
|
||||||
|
-- Tracks which MRs have had their file changes fetched, same pattern as closes_issues_synced_for_updated_at
|
||||||
|
|
||||||
|
ALTER TABLE merge_requests ADD COLUMN diffs_synced_for_updated_at INTEGER;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (20, strftime('%s', 'now') * 1000, 'MR diffs sync watermark');
|
||||||
802
src/cli/autocorrect.rs
Normal file
802
src/cli/autocorrect.rs
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use strsim::jaro_winkler;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A single correction applied to one argument.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Correction {
|
||||||
|
pub original: String,
|
||||||
|
pub corrected: String,
|
||||||
|
pub rule: CorrectionRule,
|
||||||
|
pub confidence: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which rule triggered the correction.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CorrectionRule {
|
||||||
|
SingleDashLongFlag,
|
||||||
|
CaseNormalization,
|
||||||
|
FuzzyFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the correction pass over raw args.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CorrectionResult {
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub corrections: Vec<Correction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flag registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Global flags accepted by every command (from `Cli` struct).
|
||||||
|
const GLOBAL_FLAGS: &[&str] = &[
|
||||||
|
"--config",
|
||||||
|
"--robot",
|
||||||
|
"--json",
|
||||||
|
"--color",
|
||||||
|
"--quiet",
|
||||||
|
"--no-quiet",
|
||||||
|
"--verbose",
|
||||||
|
"--no-verbose",
|
||||||
|
"--log-format",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Per-subcommand flags. Each entry is `(command_name, &[flags])`.
|
||||||
|
/// Hidden `--no-*` variants are included so they can be fuzzy-matched too.
|
||||||
|
const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||||
|
(
|
||||||
|
"issues",
|
||||||
|
&[
|
||||||
|
"--limit",
|
||||||
|
"--fields",
|
||||||
|
"--state",
|
||||||
|
"--project",
|
||||||
|
"--author",
|
||||||
|
"--assignee",
|
||||||
|
"--label",
|
||||||
|
"--milestone",
|
||||||
|
"--since",
|
||||||
|
"--due-before",
|
||||||
|
"--has-due",
|
||||||
|
"--no-has-due",
|
||||||
|
"--sort",
|
||||||
|
"--asc",
|
||||||
|
"--no-asc",
|
||||||
|
"--open",
|
||||||
|
"--no-open",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mrs",
|
||||||
|
&[
|
||||||
|
"--limit",
|
||||||
|
"--fields",
|
||||||
|
"--state",
|
||||||
|
"--project",
|
||||||
|
"--author",
|
||||||
|
"--assignee",
|
||||||
|
"--reviewer",
|
||||||
|
"--label",
|
||||||
|
"--since",
|
||||||
|
"--draft",
|
||||||
|
"--no-draft",
|
||||||
|
"--target",
|
||||||
|
"--source",
|
||||||
|
"--sort",
|
||||||
|
"--asc",
|
||||||
|
"--no-asc",
|
||||||
|
"--open",
|
||||||
|
"--no-open",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ingest",
|
||||||
|
&[
|
||||||
|
"--project",
|
||||||
|
"--force",
|
||||||
|
"--no-force",
|
||||||
|
"--full",
|
||||||
|
"--no-full",
|
||||||
|
"--dry-run",
|
||||||
|
"--no-dry-run",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sync",
|
||||||
|
&[
|
||||||
|
"--full",
|
||||||
|
"--no-full",
|
||||||
|
"--force",
|
||||||
|
"--no-force",
|
||||||
|
"--no-embed",
|
||||||
|
"--no-docs",
|
||||||
|
"--no-events",
|
||||||
|
"--no-file-changes",
|
||||||
|
"--dry-run",
|
||||||
|
"--no-dry-run",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"search",
|
||||||
|
&[
|
||||||
|
"--mode",
|
||||||
|
"--type",
|
||||||
|
"--author",
|
||||||
|
"--project",
|
||||||
|
"--label",
|
||||||
|
"--path",
|
||||||
|
"--after",
|
||||||
|
"--updated-after",
|
||||||
|
"--limit",
|
||||||
|
"--explain",
|
||||||
|
"--no-explain",
|
||||||
|
"--fts-mode",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"embed",
|
||||||
|
&["--full", "--no-full", "--retry-failed", "--no-retry-failed"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"stats",
|
||||||
|
&[
|
||||||
|
"--check",
|
||||||
|
"--no-check",
|
||||||
|
"--repair",
|
||||||
|
"--dry-run",
|
||||||
|
"--no-dry-run",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("count", &["--for"]),
|
||||||
|
(
|
||||||
|
"timeline",
|
||||||
|
&[
|
||||||
|
"--project",
|
||||||
|
"--since",
|
||||||
|
"--depth",
|
||||||
|
"--expand-mentions",
|
||||||
|
"--limit",
|
||||||
|
"--max-seeds",
|
||||||
|
"--max-entities",
|
||||||
|
"--max-evidence",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"who",
|
||||||
|
&[
|
||||||
|
"--path",
|
||||||
|
"--active",
|
||||||
|
"--overlap",
|
||||||
|
"--reviews",
|
||||||
|
"--since",
|
||||||
|
"--project",
|
||||||
|
"--limit",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"init",
|
||||||
|
&[
|
||||||
|
"--force",
|
||||||
|
"--non-interactive",
|
||||||
|
"--gitlab-url",
|
||||||
|
"--token-env-var",
|
||||||
|
"--projects",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("generate-docs", &["--full", "--project"]),
|
||||||
|
("completions", &[]),
|
||||||
|
(
|
||||||
|
"list",
|
||||||
|
&[
|
||||||
|
"--limit",
|
||||||
|
"--project",
|
||||||
|
"--state",
|
||||||
|
"--author",
|
||||||
|
"--assignee",
|
||||||
|
"--label",
|
||||||
|
"--milestone",
|
||||||
|
"--since",
|
||||||
|
"--due-before",
|
||||||
|
"--has-due-date",
|
||||||
|
"--sort",
|
||||||
|
"--order",
|
||||||
|
"--open",
|
||||||
|
"--draft",
|
||||||
|
"--no-draft",
|
||||||
|
"--reviewer",
|
||||||
|
"--target-branch",
|
||||||
|
"--source-branch",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("show", &["--project"]),
|
||||||
|
("reset", &["--yes"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Valid values for enum-like flags, used for post-clap error enhancement.
|
||||||
|
pub const ENUM_VALUES: &[(&str, &[&str])] = &[
|
||||||
|
("--state", &["opened", "closed", "merged", "locked", "all"]),
|
||||||
|
("--mode", &["lexical", "hybrid", "semantic"]),
|
||||||
|
("--sort", &["updated", "created", "iid"]),
|
||||||
|
("--type", &["issue", "mr", "discussion"]),
|
||||||
|
("--fts-mode", &["safe", "raw"]),
|
||||||
|
("--color", &["auto", "always", "never"]),
|
||||||
|
("--log-format", &["text", "json"]),
|
||||||
|
("--for", &["issue", "mr"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Correction thresholds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FUZZY_FLAG_THRESHOLD: f64 = 0.8;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Detect which subcommand is being invoked by finding the first positional
|
||||||
|
/// arg (not a flag, not a flag value).
|
||||||
|
fn detect_subcommand(args: &[String]) -> Option<&str> {
|
||||||
|
// Skip args[0] (binary name). Walk forward looking for the first
|
||||||
|
// arg that isn't a flag and isn't the value to a flag that takes one.
|
||||||
|
let mut skip_next = false;
|
||||||
|
for arg in args.iter().skip(1) {
|
||||||
|
if skip_next {
|
||||||
|
skip_next = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if arg.starts_with('-') {
|
||||||
|
// Flags that take a value: we know global ones; for simplicity
|
||||||
|
// skip the next arg for any `--flag=value` form (handled inline)
|
||||||
|
// or known value-taking global flags.
|
||||||
|
if arg.contains('=') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(arg.as_str(), "--config" | "-c" | "--color" | "--log-format") {
|
||||||
|
skip_next = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// First non-flag positional = subcommand
|
||||||
|
return Some(arg.as_str());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the set of valid long flags for the detected subcommand.
|
||||||
|
fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> {
|
||||||
|
let mut flags: Vec<&str> = GLOBAL_FLAGS.to_vec();
|
||||||
|
if let Some(cmd) = subcommand {
|
||||||
|
for (name, cmd_flags) in COMMAND_FLAGS {
|
||||||
|
if *name == cmd {
|
||||||
|
flags.extend_from_slice(cmd_flags);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No subcommand detected — include all flags for maximum matching
|
||||||
|
for (_, cmd_flags) in COMMAND_FLAGS {
|
||||||
|
for flag in *cmd_flags {
|
||||||
|
if !flags.contains(flag) {
|
||||||
|
flags.push(flag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the pre-clap correction pass on raw args.
|
||||||
|
///
|
||||||
|
/// Returns the (possibly modified) args and any corrections applied.
|
||||||
|
pub fn correct_args(raw: Vec<String>) -> 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();
|
||||||
|
|
||||||
|
for arg in raw {
|
||||||
|
if let Some(fixed) = try_correct(&arg, &valid) {
|
||||||
|
let s = fixed.corrected.clone();
|
||||||
|
corrections.push(fixed);
|
||||||
|
corrected.push(s);
|
||||||
|
} else {
|
||||||
|
corrected.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CorrectionResult {
|
||||||
|
args: corrected,
|
||||||
|
corrections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to correct a single arg. Returns `None` if no correction needed.
|
||||||
|
fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
|
||||||
|
// Only attempt correction on flag-like args (starts with `-`)
|
||||||
|
if !arg.starts_with('-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip short flags — they're unambiguous single chars (-p, -n, -v, -J)
|
||||||
|
// Also skip stacked short flags (-vvv)
|
||||||
|
if !arg.starts_with("--") {
|
||||||
|
// Rule 1: Single-dash long flag — e.g. `-robot` (len > 2, not a valid short flag)
|
||||||
|
// A short flag is `-` + single char, optionally stacked (-vvv).
|
||||||
|
// If it's `-` + multiple chars and NOT all the same char, it's likely a single-dash long flag.
|
||||||
|
let after_dash = &arg[1..];
|
||||||
|
|
||||||
|
// Check if it's a stacked short flag like -vvv (all same char)
|
||||||
|
let all_same_char = after_dash.len() > 1
|
||||||
|
&& after_dash
|
||||||
|
.chars()
|
||||||
|
.all(|c| c == after_dash.chars().next().unwrap_or('\0'));
|
||||||
|
if all_same_char {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single char = valid short flag, don't touch
|
||||||
|
if after_dash.len() == 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It looks like a single-dash long flag (e.g. `-robot`, `-state`)
|
||||||
|
let candidate = format!("--{after_dash}");
|
||||||
|
|
||||||
|
// Check exact match first (case-sensitive)
|
||||||
|
if valid_flags.contains(&candidate.as_str()) {
|
||||||
|
return Some(Correction {
|
||||||
|
original: arg.to_string(),
|
||||||
|
corrected: candidate,
|
||||||
|
rule: CorrectionRule::SingleDashLongFlag,
|
||||||
|
confidence: 0.95,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check case-insensitive exact match
|
||||||
|
let lower = candidate.to_lowercase();
|
||||||
|
if let Some(&flag) = valid_flags.iter().find(|f| f.to_lowercase() == lower) {
|
||||||
|
return Some(Correction {
|
||||||
|
original: arg.to_string(),
|
||||||
|
corrected: flag.to_string(),
|
||||||
|
rule: CorrectionRule::SingleDashLongFlag,
|
||||||
|
confidence: 0.95,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fuzzy on the single-dash candidate
|
||||||
|
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||||
|
&& score >= FUZZY_FLAG_THRESHOLD
|
||||||
|
{
|
||||||
|
return Some(Correction {
|
||||||
|
original: arg.to_string(),
|
||||||
|
corrected: best_flag.to_string(),
|
||||||
|
rule: CorrectionRule::SingleDashLongFlag,
|
||||||
|
confidence: score * 0.95, // discount slightly for compound correction
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For `--flag` or `--flag=value` forms: only correct the flag name
|
||||||
|
let (flag_part, value_suffix) = if let Some(eq_pos) = arg.find('=') {
|
||||||
|
(&arg[..eq_pos], Some(&arg[eq_pos..]))
|
||||||
|
} else {
|
||||||
|
(arg, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Already valid? No correction needed.
|
||||||
|
if valid_flags.contains(&flag_part) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Case normalization — `--Robot` -> `--robot`
|
||||||
|
let lower = flag_part.to_lowercase();
|
||||||
|
if lower != flag_part
|
||||||
|
&& let Some(&flag) = valid_flags.iter().find(|f| f.to_lowercase() == lower)
|
||||||
|
{
|
||||||
|
let corrected = match value_suffix {
|
||||||
|
Some(suffix) => format!("{flag}{suffix}"),
|
||||||
|
None => flag.to_string(),
|
||||||
|
};
|
||||||
|
return Some(Correction {
|
||||||
|
original: arg.to_string(),
|
||||||
|
corrected,
|
||||||
|
rule: CorrectionRule::CaseNormalization,
|
||||||
|
confidence: 0.9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: Fuzzy flag match — `--staate` -> `--state`
|
||||||
|
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||||
|
&& score >= FUZZY_FLAG_THRESHOLD
|
||||||
|
{
|
||||||
|
let corrected = match value_suffix {
|
||||||
|
Some(suffix) => format!("{best_flag}{suffix}"),
|
||||||
|
None => best_flag.to_string(),
|
||||||
|
};
|
||||||
|
return Some(Correction {
|
||||||
|
original: arg.to_string(),
|
||||||
|
corrected,
|
||||||
|
rule: CorrectionRule::FuzzyFlag,
|
||||||
|
confidence: score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the best fuzzy match among valid flags for a given (lowercased) input.
|
||||||
|
fn best_fuzzy_match<'a>(input: &str, valid_flags: &[&'a str]) -> Option<(&'a str, f64)> {
|
||||||
|
valid_flags
|
||||||
|
.iter()
|
||||||
|
.map(|&flag| (flag, jaro_winkler(input, flag)))
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Post-clap suggestion helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Given an unrecognized flag (from a clap error), suggest the most similar
|
||||||
|
/// valid flag for the detected subcommand.
|
||||||
|
pub fn suggest_similar_flag(invalid_flag: &str, raw_args: &[String]) -> Option<String> {
|
||||||
|
let subcommand = detect_subcommand(raw_args);
|
||||||
|
let valid = valid_flags_for(subcommand);
|
||||||
|
let lower = invalid_flag.to_lowercase();
|
||||||
|
|
||||||
|
let (best_flag, score) = best_fuzzy_match(&lower, &valid)?;
|
||||||
|
if score >= 0.6 {
|
||||||
|
Some(best_flag.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a flag name, return its valid enum values (if known).
|
||||||
|
pub fn valid_values_for_flag(flag: &str) -> Option<&'static [&'static str]> {
|
||||||
|
let lower = flag.to_lowercase();
|
||||||
|
ENUM_VALUES
|
||||||
|
.iter()
|
||||||
|
.find(|(f, _)| f.to_lowercase() == lower)
|
||||||
|
.map(|(_, vals)| *vals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a human/robot teaching note for a correction.
|
||||||
|
pub fn format_teaching_note(correction: &Correction) -> String {
|
||||||
|
match correction.rule {
|
||||||
|
CorrectionRule::SingleDashLongFlag => {
|
||||||
|
format!(
|
||||||
|
"Use double-dash for long flags: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CorrectionRule::CaseNormalization => {
|
||||||
|
format!(
|
||||||
|
"Flags are lowercase: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CorrectionRule::FuzzyFlag => {
|
||||||
|
format!(
|
||||||
|
"Correct spelling: {} (not {})",
|
||||||
|
correction.corrected, correction.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args(s: &str) -> Vec<String> {
|
||||||
|
s.split_whitespace().map(String::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Single-dash long flag ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_dash_robot() {
|
||||||
|
let result = correct_args(args("lore -robot issues -n 5"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].original, "-robot");
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||||
|
assert_eq!(
|
||||||
|
result.corrections[0].rule,
|
||||||
|
CorrectionRule::SingleDashLongFlag
|
||||||
|
);
|
||||||
|
assert_eq!(result.args, args("lore --robot issues -n 5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_dash_state() {
|
||||||
|
let result = correct_args(args("lore --robot issues -state opened"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--state");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Case normalization ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_robot() {
|
||||||
|
let result = correct_args(args("lore --Robot issues"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||||
|
assert_eq!(
|
||||||
|
result.corrections[0].rule,
|
||||||
|
CorrectionRule::CaseNormalization
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_state_upper() {
|
||||||
|
let result = correct_args(args("lore --robot issues --State opened"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--state");
|
||||||
|
assert_eq!(
|
||||||
|
result.corrections[0].rule,
|
||||||
|
CorrectionRule::CaseNormalization
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_all_upper() {
|
||||||
|
let result = correct_args(args("lore --ROBOT issues --STATE opened"));
|
||||||
|
assert_eq!(result.corrections.len(), 2);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||||
|
assert_eq!(result.corrections[1].corrected, "--state");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fuzzy flag match ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_staate() {
|
||||||
|
let result = correct_args(args("lore --robot issues --staate opened"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--state");
|
||||||
|
assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_projct() {
|
||||||
|
let result = correct_args(args("lore --robot issues --projct group/repo"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--project");
|
||||||
|
assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- No corrections ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn already_correct() {
|
||||||
|
let original = args("lore --robot issues --state opened -n 10");
|
||||||
|
let result = correct_args(original.clone());
|
||||||
|
assert!(result.corrections.is_empty());
|
||||||
|
assert_eq!(result.args, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_flags_untouched() {
|
||||||
|
let original = args("lore -J issues -n 10 -s opened -p group/repo");
|
||||||
|
let result = correct_args(original.clone());
|
||||||
|
assert!(result.corrections.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stacked_short_flags_untouched() {
|
||||||
|
let original = args("lore -vvv issues");
|
||||||
|
let result = correct_args(original.clone());
|
||||||
|
assert!(result.corrections.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positional_args_untouched() {
|
||||||
|
let result = correct_args(args("lore --robot search authentication"));
|
||||||
|
assert!(result.corrections.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wildly_wrong_flag_not_corrected() {
|
||||||
|
// `--xyzzy` shouldn't match anything above 0.8
|
||||||
|
let result = correct_args(args("lore --robot issues --xyzzy foo"));
|
||||||
|
assert!(result.corrections.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Flag with = value ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_eq_value_case_correction() {
|
||||||
|
let result = correct_args(args("lore --robot issues --State=opened"));
|
||||||
|
assert_eq!(result.corrections.len(), 1);
|
||||||
|
assert_eq!(result.corrections[0].corrected, "--state=opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Multiple corrections in one invocation ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_corrections() {
|
||||||
|
let result = correct_args(args(
|
||||||
|
"lore -robot issues --State opened --projct group/repo",
|
||||||
|
));
|
||||||
|
assert_eq!(result.corrections.len(), 3);
|
||||||
|
assert_eq!(result.args[1], "--robot");
|
||||||
|
assert_eq!(result.args[3], "--state");
|
||||||
|
assert_eq!(result.args[5], "--project");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Teaching notes ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teaching_note_single_dash() {
|
||||||
|
let c = Correction {
|
||||||
|
original: "-robot".to_string(),
|
||||||
|
corrected: "--robot".to_string(),
|
||||||
|
rule: CorrectionRule::SingleDashLongFlag,
|
||||||
|
confidence: 0.95,
|
||||||
|
};
|
||||||
|
let note = format_teaching_note(&c);
|
||||||
|
assert!(note.contains("double-dash"));
|
||||||
|
assert!(note.contains("--robot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teaching_note_case() {
|
||||||
|
let c = Correction {
|
||||||
|
original: "--State".to_string(),
|
||||||
|
corrected: "--state".to_string(),
|
||||||
|
rule: CorrectionRule::CaseNormalization,
|
||||||
|
confidence: 0.9,
|
||||||
|
};
|
||||||
|
let note = format_teaching_note(&c);
|
||||||
|
assert!(note.contains("lowercase"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teaching_note_fuzzy() {
|
||||||
|
let c = Correction {
|
||||||
|
original: "--staate".to_string(),
|
||||||
|
corrected: "--state".to_string(),
|
||||||
|
rule: CorrectionRule::FuzzyFlag,
|
||||||
|
confidence: 0.85,
|
||||||
|
};
|
||||||
|
let note = format_teaching_note(&c);
|
||||||
|
assert!(note.contains("spelling"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Post-clap suggestion helpers ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggest_similar_flag_works() {
|
||||||
|
let raw = args("lore --robot issues --xstat opened");
|
||||||
|
let suggestion = suggest_similar_flag("--xstat", &raw);
|
||||||
|
// Should suggest --state (close enough with lower threshold 0.6)
|
||||||
|
assert!(suggestion.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_values_for_state() {
|
||||||
|
let vals = valid_values_for_flag("--state");
|
||||||
|
assert!(vals.is_some());
|
||||||
|
let vals = vals.unwrap();
|
||||||
|
assert!(vals.contains(&"opened"));
|
||||||
|
assert!(vals.contains(&"closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_values_unknown_flag() {
|
||||||
|
assert!(valid_values_for_flag("--xyzzy").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Subcommand detection ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_subcommand_basic() {
|
||||||
|
assert_eq!(
|
||||||
|
detect_subcommand(&args("lore issues -n 10")),
|
||||||
|
Some("issues")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_subcommand_with_globals() {
|
||||||
|
assert_eq!(
|
||||||
|
detect_subcommand(&args("lore --robot --config /tmp/c.json mrs")),
|
||||||
|
Some("mrs")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_subcommand_with_color() {
|
||||||
|
assert_eq!(
|
||||||
|
detect_subcommand(&args("lore --color never issues")),
|
||||||
|
Some("issues")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_subcommand_none() {
|
||||||
|
assert_eq!(detect_subcommand(&args("lore --robot")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Registry drift test ----
|
||||||
|
// This test uses clap introspection to verify our static registry covers
|
||||||
|
// all long flags defined in the Cli struct.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_covers_global_flags() {
|
||||||
|
use clap::CommandFactory;
|
||||||
|
let cmd = crate::cli::Cli::command();
|
||||||
|
|
||||||
|
let clap_globals: Vec<String> = cmd
|
||||||
|
.get_arguments()
|
||||||
|
.filter_map(|a| a.get_long().map(|l| format!("--{l}")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for flag in &clap_globals {
|
||||||
|
// Skip help/version — clap adds these automatically
|
||||||
|
if flag == "--help" || flag == "--version" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
GLOBAL_FLAGS.contains(&flag.as_str()),
|
||||||
|
"Clap global flag {flag} is missing from GLOBAL_FLAGS registry. \
|
||||||
|
Add it to GLOBAL_FLAGS in autocorrect.rs."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_covers_command_flags() {
|
||||||
|
use clap::CommandFactory;
|
||||||
|
let cmd = crate::cli::Cli::command();
|
||||||
|
|
||||||
|
for sub in cmd.get_subcommands() {
|
||||||
|
let sub_name = sub.get_name().to_string();
|
||||||
|
|
||||||
|
// Find our registry entry
|
||||||
|
let registry_entry = COMMAND_FLAGS.iter().find(|(name, _)| *name == sub_name);
|
||||||
|
|
||||||
|
// Not all subcommands need entries (e.g., version, auth, status
|
||||||
|
// with no subcommand-specific flags)
|
||||||
|
let clap_flags: Vec<String> = sub
|
||||||
|
.get_arguments()
|
||||||
|
.filter_map(|a| a.get_long().map(|l| format!("--{l}")))
|
||||||
|
.filter(|f| !GLOBAL_FLAGS.contains(&f.as_str()))
|
||||||
|
.filter(|f| f != "--help" && f != "--version")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if clap_flags.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry_flags = registry_entry.map(|(_, flags)| *flags);
|
||||||
|
let registry_flags = registry_flags.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"Subcommand '{sub_name}' has clap flags {clap_flags:?} but no COMMAND_FLAGS \
|
||||||
|
registry entry. Add it to COMMAND_FLAGS in autocorrect.rs."
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
for flag in &clap_flags {
|
||||||
|
assert!(
|
||||||
|
registry_flags.contains(&flag.as_str()),
|
||||||
|
"Clap flag {flag} on subcommand '{sub_name}' is missing from \
|
||||||
|
COMMAND_FLAGS registry. Add it to the '{sub_name}' entry in autocorrect.rs."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,6 +501,20 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::ClosesIssuesFetchComplete { .. } => {
|
ProgressEvent::ClosesIssuesFetchComplete { .. } => {
|
||||||
disc_bar_clone.finish_and_clear();
|
disc_bar_clone.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
ProgressEvent::MrDiffsFetchStarted { total } => {
|
||||||
|
disc_bar_clone.reset();
|
||||||
|
disc_bar_clone.set_length(total as u64);
|
||||||
|
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||||
|
stage_bar_clone.set_message(
|
||||||
|
"Fetching MR file changes...".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ProgressEvent::MrDiffFetched { current, total: _ } => {
|
||||||
|
disc_bar_clone.set_position(current as u64);
|
||||||
|
}
|
||||||
|
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
||||||
|
disc_bar_clone.finish_and_clear();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -335,18 +335,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
|||||||
(SELECT GROUP_CONCAT(ia.username, X'1F')
|
(SELECT GROUP_CONCAT(ia.username, X'1F')
|
||||||
FROM issue_assignees ia
|
FROM issue_assignees ia
|
||||||
WHERE ia.issue_id = i.id) AS assignees_csv,
|
WHERE ia.issue_id = i.id) AS assignees_csv,
|
||||||
COALESCE(d.total, 0) AS discussion_count,
|
(SELECT COUNT(*) FROM discussions d
|
||||||
COALESCE(d.unresolved, 0) AS unresolved_count
|
WHERE d.issue_id = i.id) AS discussion_count,
|
||||||
|
(SELECT COUNT(*) FROM discussions d
|
||||||
|
WHERE d.issue_id = i.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count
|
||||||
FROM issues i
|
FROM issues i
|
||||||
JOIN projects p ON i.project_id = p.id
|
JOIN projects p ON i.project_id = p.id
|
||||||
LEFT JOIN (
|
|
||||||
SELECT issue_id,
|
|
||||||
COUNT(*) as total,
|
|
||||||
SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved
|
|
||||||
FROM discussions
|
|
||||||
WHERE issue_id IS NOT NULL
|
|
||||||
GROUP BY issue_id
|
|
||||||
) d ON d.issue_id = i.id
|
|
||||||
{where_sql}
|
{where_sql}
|
||||||
ORDER BY {sort_column} {order}
|
ORDER BY {sort_column} {order}
|
||||||
LIMIT ?"
|
LIMIT ?"
|
||||||
@@ -528,18 +522,12 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
|||||||
(SELECT GROUP_CONCAT(mr.username, X'1F')
|
(SELECT GROUP_CONCAT(mr.username, X'1F')
|
||||||
FROM mr_reviewers mr
|
FROM mr_reviewers mr
|
||||||
WHERE mr.merge_request_id = m.id) AS reviewers_csv,
|
WHERE mr.merge_request_id = m.id) AS reviewers_csv,
|
||||||
COALESCE(d.total, 0) AS discussion_count,
|
(SELECT COUNT(*) FROM discussions d
|
||||||
COALESCE(d.unresolved, 0) AS unresolved_count
|
WHERE d.merge_request_id = m.id) AS discussion_count,
|
||||||
|
(SELECT COUNT(*) FROM discussions d
|
||||||
|
WHERE d.merge_request_id = m.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count
|
||||||
FROM merge_requests m
|
FROM merge_requests m
|
||||||
JOIN projects p ON m.project_id = p.id
|
JOIN projects p ON m.project_id = p.id
|
||||||
LEFT JOIN (
|
|
||||||
SELECT merge_request_id,
|
|
||||||
COUNT(*) as total,
|
|
||||||
SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved
|
|
||||||
FROM discussions
|
|
||||||
WHERE merge_request_id IS NOT NULL
|
|
||||||
GROUP BY merge_request_id
|
|
||||||
) d ON d.merge_request_id = m.id
|
|
||||||
{where_sql}
|
{where_sql}
|
||||||
ORDER BY {sort_column} {order}
|
ORDER BY {sort_column} {order}
|
||||||
LIMIT ?"
|
LIMIT ?"
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
|||||||
// Heuristic is now only a fallback; probes decide first when ambiguous.
|
// Heuristic is now only a fallback; probes decide first when ambiguous.
|
||||||
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||||
|
|
||||||
// Probe 1: exact file exists (project-scoped via nullable binding)
|
// Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||||
let exact_exists = conn
|
let exact_exists = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT 1 FROM notes
|
"SELECT 1 FROM notes
|
||||||
@@ -445,9 +445,19 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
|||||||
rusqlite::params![trimmed, project_id],
|
rusqlite::params![trimmed, project_id],
|
||||||
|_| Ok(()),
|
|_| Ok(()),
|
||||||
)
|
)
|
||||||
|
.is_ok()
|
||||||
|
|| conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM mr_file_changes
|
||||||
|
WHERE new_path = ?1
|
||||||
|
AND (?2 IS NULL OR project_id = ?2)
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![trimmed, project_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
.is_ok();
|
.is_ok();
|
||||||
|
|
||||||
// Probe 2: directory prefix exists (project-scoped)
|
// Probe 2: directory prefix exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||||
let prefix_exists = if !forced_dir && !exact_exists {
|
let prefix_exists = if !forced_dir && !exact_exists {
|
||||||
let escaped = escape_like(trimmed);
|
let escaped = escape_like(trimmed);
|
||||||
let pat = format!("{escaped}/%");
|
let pat = format!("{escaped}/%");
|
||||||
@@ -462,6 +472,16 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
|||||||
|_| Ok(()),
|
|_| Ok(()),
|
||||||
)
|
)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
|
|| conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM mr_file_changes
|
||||||
|
WHERE new_path LIKE ?1 ESCAPE '\\'
|
||||||
|
AND (?2 IS NULL OR project_id = ?2)
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![pat, project_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
@@ -513,125 +533,117 @@ fn query_expert(
|
|||||||
let pq = build_path_query(conn, path, project_id)?;
|
let pq = build_path_query(conn, path, project_id)?;
|
||||||
let limit_plus_one = (limit + 1) as i64;
|
let limit_plus_one = (limit + 1) as i64;
|
||||||
|
|
||||||
let sql_prefix = "
|
// Build SQL with 4 signal sources (UNION ALL), deduplicating via COUNT(DISTINCT mr_id):
|
||||||
WITH activity AS (
|
// 1. DiffNote reviewer — left inline review comments (not self-review)
|
||||||
SELECT
|
// 2. DiffNote MR author — authored MR that has DiffNotes on this path
|
||||||
n.author_username AS username,
|
// 3. File-change author — authored MR that touched this path (mr_file_changes)
|
||||||
'reviewer' AS role,
|
// 4. File-change reviewer — assigned reviewer on MR that touched this path
|
||||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
let path_op = if pq.is_prefix {
|
||||||
COUNT(*) AS note_cnt,
|
"LIKE ?1 ESCAPE '\\'"
|
||||||
MAX(n.created_at) AS last_seen_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 m.state IN ('opened','merged')
|
|
||||||
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
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
m.author_username AS username,
|
|
||||||
'author' AS role,
|
|
||||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
|
||||||
0 AS note_cnt,
|
|
||||||
MAX(n.created_at) AS last_seen_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.position_new_path LIKE ?1 ESCAPE '\\'
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY m.author_username
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
username,
|
|
||||||
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_seen_at) AS last_seen_at,
|
|
||||||
(
|
|
||||||
(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
|
|
||||||
FROM activity
|
|
||||||
GROUP BY username
|
|
||||||
ORDER BY score DESC, last_seen_at DESC, username ASC
|
|
||||||
LIMIT ?4
|
|
||||||
";
|
|
||||||
|
|
||||||
let sql_exact = "
|
|
||||||
WITH activity AS (
|
|
||||||
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_seen_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 m.state IN ('opened','merged')
|
|
||||||
AND n.position_new_path = ?1
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY n.author_username
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
m.author_username AS username,
|
|
||||||
'author' AS role,
|
|
||||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
|
||||||
0 AS note_cnt,
|
|
||||||
MAX(n.created_at) AS last_seen_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.position_new_path = ?1
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY m.author_username
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
username,
|
|
||||||
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_seen_at) AS last_seen_at,
|
|
||||||
(
|
|
||||||
(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
|
|
||||||
FROM activity
|
|
||||||
GROUP BY username
|
|
||||||
ORDER BY score DESC, last_seen_at DESC, username ASC
|
|
||||||
LIMIT ?4
|
|
||||||
";
|
|
||||||
|
|
||||||
let mut stmt = if pq.is_prefix {
|
|
||||||
conn.prepare_cached(sql_prefix)?
|
|
||||||
} else {
|
} else {
|
||||||
conn.prepare_cached(sql_exact)?
|
"= ?1"
|
||||||
};
|
};
|
||||||
|
let sql = format!(
|
||||||
|
"
|
||||||
|
WITH signals AS (
|
||||||
|
-- 1. DiffNote reviewer (individual notes for note_cnt)
|
||||||
|
SELECT
|
||||||
|
n.author_username AS username,
|
||||||
|
'diffnote_reviewer' AS signal,
|
||||||
|
m.id AS mr_id,
|
||||||
|
n.id AS note_id,
|
||||||
|
n.created_at AS seen_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 m.state IN ('opened','merged')
|
||||||
|
AND n.position_new_path {path_op}
|
||||||
|
AND n.created_at >= ?2
|
||||||
|
AND (?3 IS NULL OR n.project_id = ?3)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 2. DiffNote MR author
|
||||||
|
SELECT DISTINCT
|
||||||
|
m.author_username AS username,
|
||||||
|
'diffnote_author' AS signal,
|
||||||
|
m.id AS mr_id,
|
||||||
|
NULL AS note_id,
|
||||||
|
MAX(n.created_at) AS seen_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 m.state IN ('opened','merged')
|
||||||
|
AND n.position_new_path {path_op}
|
||||||
|
AND n.created_at >= ?2
|
||||||
|
AND (?3 IS NULL OR n.project_id = ?3)
|
||||||
|
GROUP BY m.author_username, m.id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 3. MR author via file changes
|
||||||
|
SELECT
|
||||||
|
m.author_username AS username,
|
||||||
|
'file_author' AS signal,
|
||||||
|
m.id AS mr_id,
|
||||||
|
NULL AS note_id,
|
||||||
|
m.updated_at AS seen_at
|
||||||
|
FROM mr_file_changes fc
|
||||||
|
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||||
|
WHERE m.author_username IS NOT NULL
|
||||||
|
AND m.state IN ('opened','merged')
|
||||||
|
AND fc.new_path {path_op}
|
||||||
|
AND m.updated_at >= ?2
|
||||||
|
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 4. MR reviewer via file changes + mr_reviewers
|
||||||
|
SELECT
|
||||||
|
r.username AS username,
|
||||||
|
'file_reviewer' AS signal,
|
||||||
|
m.id AS mr_id,
|
||||||
|
NULL AS note_id,
|
||||||
|
m.updated_at AS seen_at
|
||||||
|
FROM mr_file_changes fc
|
||||||
|
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||||
|
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||||
|
WHERE r.username IS NOT NULL
|
||||||
|
AND m.state IN ('opened','merged')
|
||||||
|
AND fc.new_path {path_op}
|
||||||
|
AND m.updated_at >= ?2
|
||||||
|
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
username,
|
||||||
|
COUNT(DISTINCT CASE WHEN signal IN ('diffnote_reviewer', 'file_reviewer')
|
||||||
|
THEN mr_id END) AS review_mr_count,
|
||||||
|
COUNT(CASE WHEN signal = 'diffnote_reviewer' THEN note_id END) AS review_note_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN signal IN ('diffnote_author', 'file_author')
|
||||||
|
THEN mr_id END) AS author_mr_count,
|
||||||
|
MAX(seen_at) AS last_seen_at,
|
||||||
|
(
|
||||||
|
(COUNT(DISTINCT CASE WHEN signal IN ('diffnote_reviewer', 'file_reviewer')
|
||||||
|
THEN mr_id END) * 20) +
|
||||||
|
(COUNT(DISTINCT CASE WHEN signal IN ('diffnote_author', 'file_author')
|
||||||
|
THEN mr_id END) * 12) +
|
||||||
|
(COUNT(CASE WHEN signal = 'diffnote_reviewer' THEN note_id END) * 1)
|
||||||
|
) AS score
|
||||||
|
FROM signals
|
||||||
|
GROUP BY username
|
||||||
|
ORDER BY score DESC, last_seen_at DESC, username ASC
|
||||||
|
LIMIT ?4
|
||||||
|
"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare_cached(&sql)?;
|
||||||
|
|
||||||
let experts: Vec<Expert> = stmt
|
let experts: Vec<Expert> = stmt
|
||||||
.query_map(
|
.query_map(
|
||||||
@@ -1160,97 +1172,100 @@ fn query_overlap(
|
|||||||
) -> Result<OverlapResult> {
|
) -> Result<OverlapResult> {
|
||||||
let pq = build_path_query(conn, path, project_id)?;
|
let pq = build_path_query(conn, path, project_id)?;
|
||||||
|
|
||||||
let sql_prefix = "SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
// Build SQL with 4 signal sources, matching the expert query expansion.
|
||||||
SELECT
|
// Each row produces (username, role, mr_id, mr_ref, seen_at) for Rust-side accumulation.
|
||||||
n.author_username AS username,
|
let path_op = if pq.is_prefix {
|
||||||
'reviewer' AS role,
|
"LIKE ?1 ESCAPE '\\'"
|
||||||
COUNT(DISTINCT m.id) AS touch_count,
|
|
||||||
MAX(n.created_at) AS last_seen_at,
|
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
FROM notes n
|
|
||||||
JOIN discussions d ON n.discussion_id = d.id
|
|
||||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
|
||||||
JOIN projects p ON m.project_id = p.id
|
|
||||||
WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.position_new_path 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 m.state IN ('opened','merged')
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY n.author_username
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
m.author_username AS username,
|
|
||||||
'author' AS role,
|
|
||||||
COUNT(DISTINCT m.id) AS touch_count,
|
|
||||||
MAX(n.created_at) AS last_seen_at,
|
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
FROM merge_requests m
|
|
||||||
JOIN discussions d ON d.merge_request_id = m.id
|
|
||||||
JOIN notes n ON n.discussion_id = d.id
|
|
||||||
JOIN projects p ON m.project_id = p.id
|
|
||||||
WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.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 n.project_id = ?3)
|
|
||||||
GROUP BY m.author_username
|
|
||||||
)";
|
|
||||||
|
|
||||||
let sql_exact = "SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
|
||||||
SELECT
|
|
||||||
n.author_username AS username,
|
|
||||||
'reviewer' AS role,
|
|
||||||
COUNT(DISTINCT m.id) AS touch_count,
|
|
||||||
MAX(n.created_at) AS last_seen_at,
|
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
FROM notes n
|
|
||||||
JOIN discussions d ON n.discussion_id = d.id
|
|
||||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
|
||||||
JOIN projects p ON m.project_id = p.id
|
|
||||||
WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.position_new_path = ?1
|
|
||||||
AND n.is_system = 0
|
|
||||||
AND n.author_username IS NOT NULL
|
|
||||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
|
||||||
AND m.state IN ('opened','merged')
|
|
||||||
AND n.created_at >= ?2
|
|
||||||
AND (?3 IS NULL OR n.project_id = ?3)
|
|
||||||
GROUP BY n.author_username
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
m.author_username AS username,
|
|
||||||
'author' AS role,
|
|
||||||
COUNT(DISTINCT m.id) AS touch_count,
|
|
||||||
MAX(n.created_at) AS last_seen_at,
|
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
|
||||||
FROM merge_requests m
|
|
||||||
JOIN discussions d ON d.merge_request_id = m.id
|
|
||||||
JOIN notes n ON n.discussion_id = d.id
|
|
||||||
JOIN projects p ON m.project_id = p.id
|
|
||||||
WHERE n.note_type = 'DiffNote'
|
|
||||||
AND n.position_new_path = ?1
|
|
||||||
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 n.project_id = ?3)
|
|
||||||
GROUP BY m.author_username
|
|
||||||
)";
|
|
||||||
|
|
||||||
let mut stmt = if pq.is_prefix {
|
|
||||||
conn.prepare_cached(sql_prefix)?
|
|
||||||
} else {
|
} else {
|
||||||
conn.prepare_cached(sql_exact)?
|
"= ?1"
|
||||||
};
|
};
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||||
|
-- 1. DiffNote reviewer
|
||||||
|
SELECT
|
||||||
|
n.author_username AS username,
|
||||||
|
'reviewer' AS role,
|
||||||
|
COUNT(DISTINCT m.id) AS touch_count,
|
||||||
|
MAX(n.created_at) AS last_seen_at,
|
||||||
|
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||||
|
FROM notes n
|
||||||
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
|
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
|
JOIN projects p ON m.project_id = p.id
|
||||||
|
WHERE n.note_type = 'DiffNote'
|
||||||
|
AND n.position_new_path {path_op}
|
||||||
|
AND n.is_system = 0
|
||||||
|
AND n.author_username IS NOT NULL
|
||||||
|
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||||
|
AND m.state IN ('opened','merged')
|
||||||
|
AND n.created_at >= ?2
|
||||||
|
AND (?3 IS NULL OR n.project_id = ?3)
|
||||||
|
GROUP BY n.author_username
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 2. DiffNote MR author
|
||||||
|
SELECT
|
||||||
|
m.author_username AS username,
|
||||||
|
'author' AS role,
|
||||||
|
COUNT(DISTINCT m.id) AS touch_count,
|
||||||
|
MAX(n.created_at) AS last_seen_at,
|
||||||
|
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||||
|
FROM merge_requests m
|
||||||
|
JOIN discussions d ON d.merge_request_id = m.id
|
||||||
|
JOIN notes n ON n.discussion_id = d.id
|
||||||
|
JOIN projects p ON m.project_id = p.id
|
||||||
|
WHERE n.note_type = 'DiffNote'
|
||||||
|
AND n.position_new_path {path_op}
|
||||||
|
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 n.project_id = ?3)
|
||||||
|
GROUP BY m.author_username
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 3. MR author via file changes
|
||||||
|
SELECT
|
||||||
|
m.author_username AS username,
|
||||||
|
'author' AS role,
|
||||||
|
COUNT(DISTINCT m.id) AS touch_count,
|
||||||
|
MAX(m.updated_at) AS last_seen_at,
|
||||||
|
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||||
|
FROM mr_file_changes fc
|
||||||
|
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||||
|
JOIN projects p ON m.project_id = p.id
|
||||||
|
WHERE m.author_username IS NOT NULL
|
||||||
|
AND m.state IN ('opened','merged')
|
||||||
|
AND fc.new_path {path_op}
|
||||||
|
AND m.updated_at >= ?2
|
||||||
|
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||||
|
GROUP BY m.author_username
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 4. MR reviewer via file changes + mr_reviewers
|
||||||
|
SELECT
|
||||||
|
r.username AS username,
|
||||||
|
'reviewer' AS role,
|
||||||
|
COUNT(DISTINCT m.id) AS touch_count,
|
||||||
|
MAX(m.updated_at) AS last_seen_at,
|
||||||
|
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||||
|
FROM mr_file_changes fc
|
||||||
|
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||||
|
JOIN projects p ON m.project_id = p.id
|
||||||
|
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||||
|
WHERE r.username IS NOT NULL
|
||||||
|
AND m.state IN ('opened','merged')
|
||||||
|
AND fc.new_path {path_op}
|
||||||
|
AND m.updated_at >= ?2
|
||||||
|
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||||
|
GROUP BY r.username
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare_cached(&sql)?;
|
||||||
let rows: Vec<(String, String, u32, i64, Option<String>)> = stmt
|
let rows: Vec<(String, String, u32, i64, Option<String>)> = stmt
|
||||||
.query_map(rusqlite::params![pq.value, since_ms, project_id], |row| {
|
.query_map(rusqlite::params![pq.value, since_ms, project_id], |row| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -2117,7 +2132,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
|
fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
|
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
|
||||||
@@ -2126,6 +2140,21 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn insert_file_change(
|
||||||
|
conn: &Connection,
|
||||||
|
mr_id: i64,
|
||||||
|
project_id: i64,
|
||||||
|
new_path: &str,
|
||||||
|
change_type: &str,
|
||||||
|
) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params![mr_id, project_id, new_path, change_type],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_file_path_discrimination() {
|
fn test_is_file_path_discrimination() {
|
||||||
// Contains '/' -> file path
|
// Contains '/' -> file path
|
||||||
@@ -2678,4 +2707,142 @@ mod tests {
|
|||||||
let result = query_expert(&conn, "src/auth/", None, 0, 10).unwrap();
|
let result = query_expert(&conn, "src/auth/", None, 0, 10).unwrap();
|
||||||
assert!(!result.truncated);
|
assert!(!result.truncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expert_file_changes_only() {
|
||||||
|
// MR author should appear even when there are zero DiffNotes
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
insert_mr(&conn, 1, 1, 100, "file_author", "merged");
|
||||||
|
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||||
|
|
||||||
|
let result = query_expert(&conn, "src/auth/login.rs", None, 0, 20).unwrap();
|
||||||
|
assert_eq!(result.experts.len(), 1);
|
||||||
|
assert_eq!(result.experts[0].username, "file_author");
|
||||||
|
assert!(result.experts[0].author_mr_count > 0);
|
||||||
|
assert_eq!(result.experts[0].review_mr_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expert_mr_reviewer_via_file_changes() {
|
||||||
|
// A reviewer assigned via mr_reviewers should appear when that MR
|
||||||
|
// touched the queried file (via mr_file_changes)
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||||
|
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||||
|
insert_reviewer(&conn, 1, "assigned_reviewer");
|
||||||
|
|
||||||
|
let result = query_expert(&conn, "src/auth/login.rs", None, 0, 20).unwrap();
|
||||||
|
let reviewer = result
|
||||||
|
.experts
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.username == "assigned_reviewer");
|
||||||
|
assert!(reviewer.is_some(), "assigned_reviewer should appear");
|
||||||
|
assert!(reviewer.unwrap().review_mr_count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expert_deduplicates_across_signals() {
|
||||||
|
// User who is BOTH a DiffNote reviewer AND an mr_reviewers entry for
|
||||||
|
// the same MR should be counted only once per MR
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||||
|
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||||
|
insert_diffnote(
|
||||||
|
&conn,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"reviewer_b",
|
||||||
|
"src/auth/login.rs",
|
||||||
|
"looks good",
|
||||||
|
);
|
||||||
|
// Same user also listed as assigned reviewer, with file change data
|
||||||
|
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||||
|
insert_reviewer(&conn, 1, "reviewer_b");
|
||||||
|
|
||||||
|
let result = query_expert(&conn, "src/auth/login.rs", None, 0, 20).unwrap();
|
||||||
|
let reviewer = result
|
||||||
|
.experts
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.username == "reviewer_b")
|
||||||
|
.unwrap();
|
||||||
|
// Should be 1 MR, not 2 (dedup across DiffNote + mr_reviewers)
|
||||||
|
assert_eq!(reviewer.review_mr_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expert_combined_diffnote_and_file_changes() {
|
||||||
|
// Author with DiffNotes on path A and file_changes on path B should
|
||||||
|
// get credit for both when queried with a directory prefix
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
// MR 1: has DiffNotes on login.rs
|
||||||
|
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||||
|
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||||
|
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note");
|
||||||
|
// MR 2: has file_changes on session.rs (no DiffNotes)
|
||||||
|
insert_mr(&conn, 2, 1, 200, "author_a", "merged");
|
||||||
|
insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added");
|
||||||
|
|
||||||
|
let result = query_expert(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||||
|
let author = result
|
||||||
|
.experts
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.username == "author_a")
|
||||||
|
.unwrap();
|
||||||
|
// Should count 2 authored MRs (one from DiffNote path, one from file changes)
|
||||||
|
assert_eq!(author.author_mr_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expert_file_changes_prefix_match() {
|
||||||
|
// Directory prefix queries should pick up mr_file_changes under the directory
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||||
|
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||||
|
insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added");
|
||||||
|
|
||||||
|
let result = query_expert(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||||
|
assert_eq!(result.path_match, "prefix");
|
||||||
|
assert_eq!(result.experts.len(), 1);
|
||||||
|
assert_eq!(result.experts[0].username, "author_a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overlap_file_changes_only() {
|
||||||
|
// Overlap mode should also find users via mr_file_changes
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||||
|
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||||
|
insert_reviewer(&conn, 1, "reviewer_x");
|
||||||
|
|
||||||
|
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||||
|
assert!(
|
||||||
|
result.users.iter().any(|u| u.username == "author_a"),
|
||||||
|
"author_a should appear via file_changes"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.users.iter().any(|u| u.username == "reviewer_x"),
|
||||||
|
"reviewer_x should appear via mr_reviewers + file_changes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_path_query_resolves_via_file_changes() {
|
||||||
|
// DB probe should detect exact file match from mr_file_changes even
|
||||||
|
// when no DiffNotes exist for the path
|
||||||
|
let conn = setup_test_db();
|
||||||
|
insert_project(&conn, 1, "team/backend");
|
||||||
|
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||||
|
insert_file_change(&conn, 1, 1, "src/Dockerfile", "modified");
|
||||||
|
|
||||||
|
let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
|
||||||
|
assert_eq!(pq.value, "src/Dockerfile");
|
||||||
|
assert!(!pq.is_prefix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod autocorrect;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod robot;
|
pub mod robot;
|
||||||
@@ -81,11 +82,16 @@ impl Cli {
|
|||||||
|
|
||||||
/// Detect robot mode from environment before parsing succeeds.
|
/// Detect robot mode from environment before parsing succeeds.
|
||||||
/// Used for structured error output when clap parsing fails.
|
/// Used for structured error output when clap parsing fails.
|
||||||
|
/// Also catches common agent typos like `-robot` and `--Robot`.
|
||||||
pub fn detect_robot_mode_from_env() -> bool {
|
pub fn detect_robot_mode_from_env() -> bool {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
args.iter()
|
args.iter().any(|a| {
|
||||||
.any(|a| a == "--robot" || a == "-J" || a == "--json")
|
a == "-J"
|
||||||
|| std::env::var("LORE_ROBOT")
|
|| a.eq_ignore_ascii_case("--robot")
|
||||||
|
|| a.eq_ignore_ascii_case("-robot")
|
||||||
|
|| a.eq_ignore_ascii_case("--json")
|
||||||
|
|| a.eq_ignore_ascii_case("-json")
|
||||||
|
}) || std::env::var("LORE_ROBOT")
|
||||||
.ok()
|
.ok()
|
||||||
.is_some_and(|v| !v.is_empty() && v != "0" && v != "false")
|
.is_some_and(|v| !v.is_empty() && v != "0" && v != "false")
|
||||||
|| !std::io::stdout().is_terminal()
|
|| !std::io::stdout().is_terminal()
|
||||||
@@ -608,6 +614,10 @@ pub struct SyncArgs {
|
|||||||
#[arg(long = "no-events")]
|
#[arg(long = "no-events")]
|
||||||
pub no_events: bool,
|
pub no_events: bool,
|
||||||
|
|
||||||
|
/// Skip MR file change fetching (overrides config)
|
||||||
|
#[arg(long = "no-file-changes")]
|
||||||
|
pub no_file_changes: 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,
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ pub struct SyncConfig {
|
|||||||
|
|
||||||
#[serde(rename = "fetchResourceEvents", default = "default_true")]
|
#[serde(rename = "fetchResourceEvents", default = "default_true")]
|
||||||
pub fetch_resource_events: bool,
|
pub fetch_resource_events: bool,
|
||||||
|
|
||||||
|
#[serde(rename = "fetchMrFileChanges", default = "default_true")]
|
||||||
|
pub fetch_mr_file_changes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -66,6 +69,7 @@ impl Default for SyncConfig {
|
|||||||
dependent_concurrency: 8,
|
dependent_concurrency: 8,
|
||||||
requests_per_second: 30.0,
|
requests_per_second: 30.0,
|
||||||
fetch_resource_events: true,
|
fetch_resource_events: true,
|
||||||
|
fetch_mr_file_changes: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
"018",
|
"018",
|
||||||
include_str!("../../migrations/018_fix_assignees_composite_index.sql"),
|
include_str!("../../migrations/018_fix_assignees_composite_index.sql"),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"019",
|
||||||
|
include_str!("../../migrations/019_list_performance.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"020",
|
||||||
|
include_str!("../../migrations/020_mr_diffs_watermark.sql"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use tracing::{debug, warn};
|
|||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabMergeRequest,
|
GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabMergeRequest,
|
||||||
GitLabMilestoneEvent, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion,
|
GitLabMilestoneEvent, GitLabMrDiff, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion,
|
||||||
};
|
};
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
|
|
||||||
@@ -609,6 +609,15 @@ impl GitLabClient {
|
|||||||
self.fetch_all_pages(&path).await
|
self.fetch_all_pages(&path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_mr_diffs(
|
||||||
|
&self,
|
||||||
|
gitlab_project_id: i64,
|
||||||
|
iid: i64,
|
||||||
|
) -> Result<Vec<GitLabMrDiff>> {
|
||||||
|
let path = format!("/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/diffs");
|
||||||
|
coalesce_not_found(self.fetch_all_pages(&path).await)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_issue_state_events(
|
pub async fn fetch_issue_state_events(
|
||||||
&self,
|
&self,
|
||||||
gitlab_project_id: i64,
|
gitlab_project_id: i64,
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ pub use transformers::{
|
|||||||
};
|
};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabLabelRef,
|
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabLabelRef,
|
||||||
GitLabMergeRequestRef, GitLabMilestoneEvent, GitLabMilestoneRef, GitLabNote,
|
GitLabMergeRequestRef, GitLabMilestoneEvent, GitLabMilestoneRef, GitLabMrDiff, GitLabNote,
|
||||||
GitLabNotePosition, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion,
|
GitLabNotePosition, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -214,6 +214,18 @@ pub struct GitLabReviewer {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct GitLabMrDiff {
|
||||||
|
pub old_path: String,
|
||||||
|
pub new_path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub new_file: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub renamed_file: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted_file: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct GitLabMergeRequest {
|
pub struct GitLabMergeRequest {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod discussion_queue;
|
|||||||
pub mod discussions;
|
pub mod discussions;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod merge_requests;
|
pub mod merge_requests;
|
||||||
|
pub mod mr_diffs;
|
||||||
pub mod mr_discussions;
|
pub mod mr_discussions;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
|
||||||
|
|||||||
268
src/ingestion/mr_diffs.rs
Normal file
268
src/ingestion/mr_diffs.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::core::error::Result;
|
||||||
|
use crate::gitlab::types::GitLabMrDiff;
|
||||||
|
|
||||||
|
/// Derive the change type from GitLab's boolean flags.
|
||||||
|
fn derive_change_type(diff: &GitLabMrDiff) -> &'static str {
|
||||||
|
if diff.new_file {
|
||||||
|
"added"
|
||||||
|
} else if diff.renamed_file {
|
||||||
|
"renamed"
|
||||||
|
} else if diff.deleted_file {
|
||||||
|
"deleted"
|
||||||
|
} else {
|
||||||
|
"modified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace all file change records for a given MR with the provided diffs.
|
||||||
|
/// Uses DELETE+INSERT (simpler than UPSERT for array replacement).
|
||||||
|
pub fn upsert_mr_file_changes(
|
||||||
|
conn: &Connection,
|
||||||
|
mr_local_id: i64,
|
||||||
|
project_id: i64,
|
||||||
|
diffs: &[GitLabMrDiff],
|
||||||
|
) -> Result<usize> {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM mr_file_changes WHERE merge_request_id = ?1",
|
||||||
|
[mr_local_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare_cached(
|
||||||
|
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut inserted = 0;
|
||||||
|
for diff in diffs {
|
||||||
|
let old_path = if diff.renamed_file {
|
||||||
|
Some(diff.old_path.as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let change_type = derive_change_type(diff);
|
||||||
|
|
||||||
|
stmt.execute(rusqlite::params![
|
||||||
|
mr_local_id,
|
||||||
|
project_id,
|
||||||
|
old_path,
|
||||||
|
diff.new_path,
|
||||||
|
change_type,
|
||||||
|
])?;
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if inserted > 0 {
|
||||||
|
debug!(inserted, mr_local_id, "Stored MR file changes");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(inserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::db::{create_connection, run_migrations};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn setup() -> Connection {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
|
||||||
|
// Insert a test project
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Insert a test MR
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO merge_requests (gitlab_id, iid, project_id, title, state, draft, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) \
|
||||||
|
VALUES (100, 1, 1, 'Test MR', 'merged', 0, 'feature', 'main', 'testuser', 1000, 2000, 3000)",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_change_type_added() {
|
||||||
|
let diff = GitLabMrDiff {
|
||||||
|
old_path: String::new(),
|
||||||
|
new_path: "src/new.rs".to_string(),
|
||||||
|
new_file: true,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
};
|
||||||
|
assert_eq!(derive_change_type(&diff), "added");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_change_type_renamed() {
|
||||||
|
let diff = GitLabMrDiff {
|
||||||
|
old_path: "src/old.rs".to_string(),
|
||||||
|
new_path: "src/new.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: true,
|
||||||
|
deleted_file: false,
|
||||||
|
};
|
||||||
|
assert_eq!(derive_change_type(&diff), "renamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_change_type_deleted() {
|
||||||
|
let diff = GitLabMrDiff {
|
||||||
|
old_path: "src/gone.rs".to_string(),
|
||||||
|
new_path: "src/gone.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: true,
|
||||||
|
};
|
||||||
|
assert_eq!(derive_change_type(&diff), "deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_change_type_modified() {
|
||||||
|
let diff = GitLabMrDiff {
|
||||||
|
old_path: "src/lib.rs".to_string(),
|
||||||
|
new_path: "src/lib.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
};
|
||||||
|
assert_eq!(derive_change_type(&diff), "modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_inserts_file_changes() {
|
||||||
|
let conn = setup();
|
||||||
|
let diffs = [
|
||||||
|
GitLabMrDiff {
|
||||||
|
old_path: String::new(),
|
||||||
|
new_path: "src/new.rs".to_string(),
|
||||||
|
new_file: true,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
},
|
||||||
|
GitLabMrDiff {
|
||||||
|
old_path: "src/lib.rs".to_string(),
|
||||||
|
new_path: "src/lib.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||||
|
assert_eq!(inserted, 2);
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_replaces_existing() {
|
||||||
|
let conn = setup();
|
||||||
|
let diffs_v1 = [GitLabMrDiff {
|
||||||
|
old_path: String::new(),
|
||||||
|
new_path: "src/old.rs".to_string(),
|
||||||
|
new_file: true,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
}];
|
||||||
|
upsert_mr_file_changes(&conn, 1, 1, &diffs_v1).unwrap();
|
||||||
|
|
||||||
|
let diffs_v2 = [
|
||||||
|
GitLabMrDiff {
|
||||||
|
old_path: "src/a.rs".to_string(),
|
||||||
|
new_path: "src/a.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
},
|
||||||
|
GitLabMrDiff {
|
||||||
|
old_path: "src/b.rs".to_string(),
|
||||||
|
new_path: "src/b.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs_v2).unwrap();
|
||||||
|
assert_eq!(inserted, 2);
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
|
||||||
|
// The old "src/old.rs" should be gone
|
||||||
|
let old_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM mr_file_changes WHERE new_path = 'src/old.rs'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(old_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_renamed_stores_old_path() {
|
||||||
|
let conn = setup();
|
||||||
|
let diffs = [GitLabMrDiff {
|
||||||
|
old_path: "src/old_name.rs".to_string(),
|
||||||
|
new_path: "src/new_name.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: true,
|
||||||
|
deleted_file: false,
|
||||||
|
}];
|
||||||
|
|
||||||
|
upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||||
|
|
||||||
|
let (old_path, change_type): (Option<String>, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT old_path, change_type FROM mr_file_changes WHERE new_path = 'src/new_name.rs'",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(old_path.as_deref(), Some("src/old_name.rs"));
|
||||||
|
assert_eq!(change_type, "renamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_renamed_has_null_old_path() {
|
||||||
|
let conn = setup();
|
||||||
|
let diffs = [GitLabMrDiff {
|
||||||
|
old_path: "src/lib.rs".to_string(),
|
||||||
|
new_path: "src/lib.rs".to_string(),
|
||||||
|
new_file: false,
|
||||||
|
renamed_file: false,
|
||||||
|
deleted_file: false,
|
||||||
|
}];
|
||||||
|
|
||||||
|
upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||||
|
|
||||||
|
let old_path: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT old_path FROM mr_file_changes WHERE new_path = 'src/lib.rs'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(old_path.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ pub enum ProgressEvent {
|
|||||||
ClosesIssuesFetchStarted { total: usize },
|
ClosesIssuesFetchStarted { total: usize },
|
||||||
ClosesIssueFetched { current: usize, total: usize },
|
ClosesIssueFetched { current: usize, total: usize },
|
||||||
ClosesIssuesFetchComplete { fetched: usize, failed: usize },
|
ClosesIssuesFetchComplete { fetched: usize, failed: usize },
|
||||||
|
MrDiffsFetchStarted { total: usize },
|
||||||
|
MrDiffFetched { current: usize, total: usize },
|
||||||
|
MrDiffsFetchComplete { fetched: usize, failed: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -76,6 +79,8 @@ pub struct IngestMrProjectResult {
|
|||||||
pub resource_events_failed: usize,
|
pub resource_events_failed: usize,
|
||||||
pub closes_issues_fetched: usize,
|
pub closes_issues_fetched: usize,
|
||||||
pub closes_issues_failed: usize,
|
pub closes_issues_failed: usize,
|
||||||
|
pub mr_diffs_fetched: usize,
|
||||||
|
pub mr_diffs_failed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ingest_project_issues(
|
pub async fn ingest_project_issues(
|
||||||
@@ -466,6 +471,31 @@ pub async fn ingest_project_merge_requests_with_progress(
|
|||||||
result.closes_issues_failed = closes_result.failed;
|
result.closes_issues_failed = closes_result.failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if signal.is_cancelled() {
|
||||||
|
info!("Shutdown requested, returning partial MR results");
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.sync.fetch_mr_file_changes {
|
||||||
|
let enqueued = enqueue_mr_diffs_jobs(conn, project_id)?;
|
||||||
|
if enqueued > 0 {
|
||||||
|
debug!(enqueued, "Enqueued mr_diffs jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
let diffs_result = drain_mr_diffs(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
project_id,
|
||||||
|
gitlab_project_id,
|
||||||
|
&progress,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
result.mr_diffs_fetched = diffs_result.fetched;
|
||||||
|
result.mr_diffs_failed = diffs_result.failed;
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
mrs_fetched = result.mrs_fetched,
|
mrs_fetched = result.mrs_fetched,
|
||||||
mrs_upserted = result.mrs_upserted,
|
mrs_upserted = result.mrs_upserted,
|
||||||
@@ -479,6 +509,8 @@ pub async fn ingest_project_merge_requests_with_progress(
|
|||||||
resource_events_failed = result.resource_events_failed,
|
resource_events_failed = result.resource_events_failed,
|
||||||
closes_issues_fetched = result.closes_issues_fetched,
|
closes_issues_fetched = result.closes_issues_fetched,
|
||||||
closes_issues_failed = result.closes_issues_failed,
|
closes_issues_failed = result.closes_issues_failed,
|
||||||
|
mr_diffs_fetched = result.mr_diffs_fetched,
|
||||||
|
mr_diffs_failed = result.mr_diffs_failed,
|
||||||
"MR project ingestion complete"
|
"MR project ingestion complete"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1188,6 +1220,235 @@ fn store_closes_issues_refs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── MR Diffs (file changes) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn enqueue_mr_diffs_jobs(conn: &Connection, project_id: i64) -> Result<usize> {
|
||||||
|
// Remove stale jobs for MRs that haven't changed since their last diffs sync
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM pending_dependent_fetches \
|
||||||
|
WHERE project_id = ?1 AND entity_type = 'merge_request' AND job_type = 'mr_diffs' \
|
||||||
|
AND entity_local_id IN ( \
|
||||||
|
SELECT id FROM merge_requests \
|
||||||
|
WHERE project_id = ?1 \
|
||||||
|
AND updated_at <= COALESCE(diffs_synced_for_updated_at, 0) \
|
||||||
|
)",
|
||||||
|
[project_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare_cached(
|
||||||
|
"SELECT id, iid FROM merge_requests \
|
||||||
|
WHERE project_id = ?1 \
|
||||||
|
AND updated_at > COALESCE(diffs_synced_for_updated_at, 0)",
|
||||||
|
)?;
|
||||||
|
let entities: Vec<(i64, i64)> = stmt
|
||||||
|
.query_map([project_id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let mut enqueued = 0;
|
||||||
|
for (local_id, iid) in &entities {
|
||||||
|
if enqueue_job(
|
||||||
|
conn,
|
||||||
|
project_id,
|
||||||
|
"merge_request",
|
||||||
|
*iid,
|
||||||
|
*local_id,
|
||||||
|
"mr_diffs",
|
||||||
|
None,
|
||||||
|
)? {
|
||||||
|
enqueued += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(enqueued)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrefetchedMrDiffs {
|
||||||
|
job_id: i64,
|
||||||
|
entity_iid: i64,
|
||||||
|
entity_local_id: i64,
|
||||||
|
result:
|
||||||
|
std::result::Result<Vec<crate::gitlab::types::GitLabMrDiff>, crate::core::error::LoreError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prefetch_mr_diffs(
|
||||||
|
client: &GitLabClient,
|
||||||
|
gitlab_project_id: i64,
|
||||||
|
job_id: i64,
|
||||||
|
entity_iid: i64,
|
||||||
|
entity_local_id: i64,
|
||||||
|
) -> PrefetchedMrDiffs {
|
||||||
|
let result = client.fetch_mr_diffs(gitlab_project_id, entity_iid).await;
|
||||||
|
PrefetchedMrDiffs {
|
||||||
|
job_id,
|
||||||
|
entity_iid,
|
||||||
|
entity_local_id,
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(
|
||||||
|
skip(conn, client, config, progress, signal),
|
||||||
|
fields(project_id, gitlab_project_id, items_processed, errors)
|
||||||
|
)]
|
||||||
|
async fn drain_mr_diffs(
|
||||||
|
conn: &Connection,
|
||||||
|
client: &GitLabClient,
|
||||||
|
config: &Config,
|
||||||
|
project_id: i64,
|
||||||
|
gitlab_project_id: i64,
|
||||||
|
progress: &Option<ProgressCallback>,
|
||||||
|
signal: &ShutdownSignal,
|
||||||
|
) -> Result<DrainResult> {
|
||||||
|
let mut result = DrainResult::default();
|
||||||
|
let batch_size = config.sync.dependent_concurrency as usize;
|
||||||
|
|
||||||
|
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||||
|
if reclaimed > 0 {
|
||||||
|
info!(reclaimed, "Reclaimed stale mr_diffs locks");
|
||||||
|
}
|
||||||
|
|
||||||
|
let claimable_counts = count_claimable_jobs(conn, project_id)?;
|
||||||
|
let total_pending = claimable_counts.get("mr_diffs").copied().unwrap_or(0);
|
||||||
|
|
||||||
|
if total_pending == 0 {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let emit = |event: ProgressEvent| {
|
||||||
|
if let Some(cb) = progress {
|
||||||
|
cb(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
emit(ProgressEvent::MrDiffsFetchStarted {
|
||||||
|
total: total_pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut processed = 0;
|
||||||
|
let mut seen_job_ids = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if signal.is_cancelled() {
|
||||||
|
info!("Shutdown requested during mr_diffs drain, returning partial results");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobs = claim_jobs(conn, "mr_diffs", project_id, batch_size)?;
|
||||||
|
if jobs.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Concurrent HTTP fetches
|
||||||
|
let futures: Vec<_> = jobs
|
||||||
|
.iter()
|
||||||
|
.filter(|j| seen_job_ids.insert(j.id))
|
||||||
|
.map(|j| {
|
||||||
|
prefetch_mr_diffs(
|
||||||
|
client,
|
||||||
|
gitlab_project_id,
|
||||||
|
j.id,
|
||||||
|
j.entity_iid,
|
||||||
|
j.entity_local_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if futures.is_empty() {
|
||||||
|
warn!("All claimed mr_diffs jobs were already processed, breaking drain loop");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefetched = join_all(futures).await;
|
||||||
|
|
||||||
|
// Phase 2: Serial DB writes
|
||||||
|
for p in prefetched {
|
||||||
|
match p.result {
|
||||||
|
Ok(diffs) => {
|
||||||
|
let store_result = super::mr_diffs::upsert_mr_file_changes(
|
||||||
|
conn,
|
||||||
|
p.entity_local_id,
|
||||||
|
project_id,
|
||||||
|
&diffs,
|
||||||
|
);
|
||||||
|
|
||||||
|
match store_result {
|
||||||
|
Ok(_) => {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
complete_job_tx(&tx, p.job_id)?;
|
||||||
|
update_diffs_watermark_tx(&tx, p.entity_local_id)?;
|
||||||
|
tx.commit()?;
|
||||||
|
result.fetched += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
entity_iid = p.entity_iid,
|
||||||
|
error = %e,
|
||||||
|
"Failed to store MR file changes"
|
||||||
|
);
|
||||||
|
fail_job(conn, p.job_id, &e.to_string())?;
|
||||||
|
result.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if e.is_permanent_api_error() {
|
||||||
|
debug!(
|
||||||
|
entity_iid = p.entity_iid,
|
||||||
|
error = %e,
|
||||||
|
"Permanent API error for mr_diffs, marking complete"
|
||||||
|
);
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
complete_job_tx(&tx, p.job_id)?;
|
||||||
|
update_diffs_watermark_tx(&tx, p.entity_local_id)?;
|
||||||
|
tx.commit()?;
|
||||||
|
result.skipped_not_found += 1;
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
entity_iid = p.entity_iid,
|
||||||
|
error = %e,
|
||||||
|
"Failed to fetch MR diffs from GitLab"
|
||||||
|
);
|
||||||
|
fail_job(conn, p.job_id, &e.to_string())?;
|
||||||
|
result.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed += 1;
|
||||||
|
emit(ProgressEvent::MrDiffFetched {
|
||||||
|
current: processed,
|
||||||
|
total: total_pending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(ProgressEvent::MrDiffsFetchComplete {
|
||||||
|
fetched: result.fetched,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
|
||||||
|
if result.fetched > 0 || result.failed > 0 {
|
||||||
|
info!(
|
||||||
|
fetched = result.fetched,
|
||||||
|
failed = result.failed,
|
||||||
|
"mr_diffs drain complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::Span::current().record("items_processed", result.fetched);
|
||||||
|
tracing::Span::current().record("errors", result.failed);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_diffs_watermark_tx(tx: &rusqlite::Transaction<'_>, mr_local_id: i64) -> Result<()> {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE merge_requests SET diffs_synced_for_updated_at = updated_at WHERE id = ?",
|
||||||
|
[mr_local_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
183
src/main.rs
183
src/main.rs
@@ -8,6 +8,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
|||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
use lore::Config;
|
use lore::Config;
|
||||||
|
use lore::cli::autocorrect::{self, CorrectionResult};
|
||||||
use lore::cli::commands::{
|
use lore::cli::commands::{
|
||||||
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
||||||
SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, open_mr_in_browser,
|
SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, open_mr_in_browser,
|
||||||
@@ -49,10 +50,20 @@ async fn main() {
|
|||||||
// Phase 1: Early robot mode detection for structured clap errors
|
// Phase 1: Early robot mode detection for structured clap errors
|
||||||
let robot_mode_early = Cli::detect_robot_mode_from_env();
|
let robot_mode_early = Cli::detect_robot_mode_from_env();
|
||||||
|
|
||||||
let cli = match Cli::try_parse() {
|
// Phase 1.5: Pre-clap arg correction for agent typo tolerance
|
||||||
|
let raw_args: Vec<String> = std::env::args().collect();
|
||||||
|
let correction_result = autocorrect::correct_args(raw_args);
|
||||||
|
|
||||||
|
// Emit correction warnings to stderr (before clap parsing, so they appear
|
||||||
|
// even if clap still fails on something else)
|
||||||
|
if !correction_result.corrections.is_empty() {
|
||||||
|
emit_correction_warnings(&correction_result, robot_mode_early);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli = match Cli::try_parse_from(&correction_result.args) {
|
||||||
Ok(cli) => cli,
|
Ok(cli) => cli,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
handle_clap_error(e, robot_mode_early);
|
handle_clap_error(e, robot_mode_early, &correction_result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let robot_mode = cli.is_robot_mode();
|
let robot_mode = cli.is_robot_mode();
|
||||||
@@ -386,9 +397,50 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit stderr warnings for any corrections applied during Phase 1.5.
|
||||||
|
fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
|
||||||
|
if robot_mode {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CorrectionWarning<'a> {
|
||||||
|
warning: CorrectionWarningInner<'a>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CorrectionWarningInner<'a> {
|
||||||
|
r#type: &'static str,
|
||||||
|
corrections: &'a [autocorrect::Correction],
|
||||||
|
teaching: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let teaching: Vec<String> = result
|
||||||
|
.corrections
|
||||||
|
.iter()
|
||||||
|
.map(autocorrect::format_teaching_note)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let warning = CorrectionWarning {
|
||||||
|
warning: CorrectionWarningInner {
|
||||||
|
r#type: "ARG_CORRECTED",
|
||||||
|
corrections: &result.corrections,
|
||||||
|
teaching,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&warning) {
|
||||||
|
eprintln!("{json}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for c in &result.corrections {
|
||||||
|
eprintln!(
|
||||||
|
"{} {}",
|
||||||
|
style("Auto-corrected:").yellow(),
|
||||||
|
autocorrect::format_teaching_note(c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode.
|
/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode.
|
||||||
/// Also includes fuzzy command matching to suggest similar commands.
|
/// Also includes fuzzy command matching and flag-level suggestions.
|
||||||
fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
|
fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionResult) -> ! {
|
||||||
use clap::error::ErrorKind;
|
use clap::error::ErrorKind;
|
||||||
|
|
||||||
// Always let clap handle --help and --version normally (print and exit 0).
|
// Always let clap handle --help and --version normally (print and exit 0).
|
||||||
@@ -406,15 +458,58 @@ fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
|
|||||||
.unwrap_or("Parse error")
|
.unwrap_or("Parse error")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Phase 4: Try to suggest similar command for unknown commands
|
let (suggestion, correction, valid_values) = match e.kind() {
|
||||||
let suggestion = if e.kind() == ErrorKind::InvalidSubcommand {
|
// Phase 4: Suggest similar command for unknown subcommands
|
||||||
if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
|
ErrorKind::InvalidSubcommand => {
|
||||||
|
let suggestion = if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
|
||||||
suggest_similar_command(&invalid_cmd)
|
suggest_similar_command(&invalid_cmd)
|
||||||
} else {
|
} else {
|
||||||
"Run 'lore robot-docs' for valid commands".to_string()
|
"Run 'lore robot-docs' for valid commands".to_string()
|
||||||
|
};
|
||||||
|
(suggestion, None, None)
|
||||||
|
}
|
||||||
|
// Flag-level fuzzy matching for unknown flags
|
||||||
|
ErrorKind::UnknownArgument => {
|
||||||
|
let invalid_flag = extract_invalid_flag(&e);
|
||||||
|
let similar = invalid_flag
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|flag| autocorrect::suggest_similar_flag(flag, &corrections.args));
|
||||||
|
let suggestion = if let Some(ref s) = similar {
|
||||||
|
format!("Did you mean '{s}'? Run 'lore robot-docs' for all flags")
|
||||||
|
} else {
|
||||||
|
"Run 'lore robot-docs' for valid flags".to_string()
|
||||||
|
};
|
||||||
|
(suggestion, similar, None)
|
||||||
|
}
|
||||||
|
// Value-level suggestions for invalid enum values
|
||||||
|
ErrorKind::InvalidValue => {
|
||||||
|
let (flag, valid_vals) = extract_invalid_value_context(&e);
|
||||||
|
let suggestion = if let Some(vals) = &valid_vals {
|
||||||
|
format!(
|
||||||
|
"Valid values: {}. Run 'lore robot-docs' for details",
|
||||||
|
vals.join(", ")
|
||||||
|
)
|
||||||
|
} else if let Some(ref f) = flag {
|
||||||
|
if let Some(vals) = autocorrect::valid_values_for_flag(f) {
|
||||||
|
format!("Valid values for {f}: {}", vals.join(", "))
|
||||||
|
} else {
|
||||||
|
"Run 'lore robot-docs' for valid values".to_string()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
"Run 'lore robot-docs' for valid commands".to_string()
|
"Run 'lore robot-docs' for valid values".to_string()
|
||||||
|
};
|
||||||
|
let vals_vec = valid_vals.or_else(|| {
|
||||||
|
flag.as_deref()
|
||||||
|
.and_then(autocorrect::valid_values_for_flag)
|
||||||
|
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
|
||||||
|
});
|
||||||
|
(suggestion, None, vals_vec)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
"Run 'lore robot-docs' for valid commands".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = RobotErrorWithSuggestion {
|
let output = RobotErrorWithSuggestion {
|
||||||
@@ -422,6 +517,8 @@ fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
|
|||||||
code: error_code.to_string(),
|
code: error_code.to_string(),
|
||||||
message,
|
message,
|
||||||
suggestion,
|
suggestion,
|
||||||
|
correction,
|
||||||
|
valid_values,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -467,6 +564,61 @@ fn extract_invalid_subcommand(e: &clap::Error) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the invalid flag from a clap UnknownArgument error.
|
||||||
|
/// Format is typically: "error: unexpected argument '--xyzzy' found"
|
||||||
|
fn extract_invalid_flag(e: &clap::Error) -> Option<String> {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if let Some(start) = msg.find('\'')
|
||||||
|
&& let Some(end) = msg[start + 1..].find('\'')
|
||||||
|
{
|
||||||
|
let value = &msg[start + 1..start + 1 + end];
|
||||||
|
if value.starts_with('-') {
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract flag name and valid values from a clap InvalidValue error.
|
||||||
|
/// Returns (flag_name, valid_values_if_listed_in_error).
|
||||||
|
fn extract_invalid_value_context(e: &clap::Error) -> (Option<String>, Option<Vec<String>>) {
|
||||||
|
let msg = e.to_string();
|
||||||
|
|
||||||
|
// Try to find the flag name from "[possible values: ...]" pattern or from the arg info
|
||||||
|
// Clap format: "error: invalid value 'opend' for '--state <STATE>'"
|
||||||
|
let flag = if let Some(for_pos) = msg.find("for '") {
|
||||||
|
let after_for = &msg[for_pos + 5..];
|
||||||
|
if let Some(end) = after_for.find('\'') {
|
||||||
|
let raw = &after_for[..end];
|
||||||
|
// Strip angle-bracket value placeholder: "--state <STATE>" -> "--state"
|
||||||
|
Some(raw.split_whitespace().next().unwrap_or(raw).to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to extract possible values from the error message
|
||||||
|
// Clap format: "[possible values: opened, closed, merged, locked, all]"
|
||||||
|
let valid_values = if let Some(pv_pos) = msg.find("[possible values: ") {
|
||||||
|
let after_pv = &msg[pv_pos + 18..];
|
||||||
|
after_pv.find(']').map(|end| {
|
||||||
|
after_pv[..end]
|
||||||
|
.split(", ")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fall back to our static registry
|
||||||
|
flag.as_deref()
|
||||||
|
.and_then(autocorrect::valid_values_for_flag)
|
||||||
|
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
(flag, valid_values)
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase 4: Suggest similar command using fuzzy matching
|
/// Phase 4: Suggest similar command using fuzzy matching
|
||||||
fn suggest_similar_command(invalid: &str) -> String {
|
fn suggest_similar_command(invalid: &str) -> String {
|
||||||
const VALID_COMMANDS: &[&str] = &[
|
const VALID_COMMANDS: &[&str] = &[
|
||||||
@@ -1009,6 +1161,8 @@ async fn handle_init(
|
|||||||
code: "MISSING_FLAGS".to_string(),
|
code: "MISSING_FLAGS".to_string(),
|
||||||
message: format!("Robot mode requires flags: {}", missing.join(", ")),
|
message: format!("Robot mode requires flags: {}", missing.join(", ")),
|
||||||
suggestion: "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project".to_string(),
|
suggestion: "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project".to_string(),
|
||||||
|
correction: None,
|
||||||
|
valid_values: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
eprintln!("{}", serde_json::to_string(&output)?);
|
eprintln!("{}", serde_json::to_string(&output)?);
|
||||||
@@ -1347,6 +1501,8 @@ fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
code: "NOT_IMPLEMENTED".to_string(),
|
code: "NOT_IMPLEMENTED".to_string(),
|
||||||
message: "The 'backup' command is not yet implemented.".to_string(),
|
message: "The 'backup' command is not yet implemented.".to_string(),
|
||||||
suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(),
|
suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(),
|
||||||
|
correction: None,
|
||||||
|
valid_values: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
eprintln!("{}", serde_json::to_string(&output)?);
|
eprintln!("{}", serde_json::to_string(&output)?);
|
||||||
@@ -1367,6 +1523,8 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
message: "The 'reset' command is not yet implemented.".to_string(),
|
message: "The 'reset' command is not yet implemented.".to_string(),
|
||||||
suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db"
|
suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
correction: None,
|
||||||
|
valid_values: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
eprintln!("{}", serde_json::to_string(&output)?);
|
eprintln!("{}", serde_json::to_string(&output)?);
|
||||||
@@ -1403,6 +1561,10 @@ struct RobotErrorSuggestionData {
|
|||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
suggestion: String,
|
suggestion: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
correction: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
valid_values: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_migrate(
|
async fn handle_migrate(
|
||||||
@@ -1420,6 +1582,8 @@ async fn handle_migrate(
|
|||||||
code: "DB_ERROR".to_string(),
|
code: "DB_ERROR".to_string(),
|
||||||
message: format!("Database not found at {}", db_path.display()),
|
message: format!("Database not found at {}", db_path.display()),
|
||||||
suggestion: "Run 'lore init' first".to_string(),
|
suggestion: "Run 'lore init' first".to_string(),
|
||||||
|
correction: None,
|
||||||
|
valid_values: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
eprintln!("{}", serde_json::to_string(&output)?);
|
eprintln!("{}", serde_json::to_string(&output)?);
|
||||||
@@ -1625,6 +1789,9 @@ async fn handle_sync_cmd(
|
|||||||
if args.no_events {
|
if args.no_events {
|
||||||
config.sync.fetch_resource_events = false;
|
config.sync.fetch_resource_events = false;
|
||||||
}
|
}
|
||||||
|
if args.no_file_changes {
|
||||||
|
config.sync.fetch_mr_file_changes = false;
|
||||||
|
}
|
||||||
let options = SyncOptions {
|
let options = SyncOptions {
|
||||||
full: args.full && !args.no_full,
|
full: args.full && !args.no_full,
|
||||||
force: args.force && !args.no_force,
|
force: args.force && !args.no_force,
|
||||||
|
|||||||
Reference in New Issue
Block a user