From bf977eca1a73c512427f6baf085c47e167027ed9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 6 Mar 2026 15:22:42 -0500 Subject: [PATCH] refactor(structure): reorganize codebase into domain-focused modules --- Cargo.toml | 3 + PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md | 1017 ++++++----- rust-toolchain.toml | 2 + src/app/dispatch.rs | 3 + src/app/errors.rs | 478 +++++ src/app/robot_docs.rs | 821 +++++++++ src/cli/args.rs | 870 ++++++++++ src/cli/commands/count.rs | 4 +- src/cli/commands/ingest/mod.rs | 26 + src/cli/commands/ingest/render.rs | 331 ++++ src/cli/commands/{ingest.rs => ingest/run.rs} | 355 ---- src/cli/commands/list.rs | 1383 --------------- src/cli/commands/list/issues.rs | 443 +++++ src/cli/commands/{ => list}/list_tests.rs | 51 +- src/cli/commands/list/mod.rs | 28 + src/cli/commands/list/mrs.rs | 404 +++++ src/cli/commands/list/notes.rs | 470 +++++ src/cli/commands/list/render_helpers.rs | 73 + src/cli/commands/me/me_tests.rs | 23 +- src/cli/commands/mod.rs | 4 +- src/cli/commands/show.rs | 1544 ----------------- src/cli/commands/show/issue.rs | 310 ++++ src/cli/commands/show/mod.rs | 19 + src/cli/commands/show/mr.rs | 283 +++ src/cli/commands/show/render.rs | 580 +++++++ src/cli/commands/show/show_tests.rs | 353 ++++ src/cli/commands/sync.rs | 1201 ------------- src/cli/commands/sync/mod.rs | 24 + src/cli/commands/sync/render.rs | 533 ++++++ src/cli/commands/sync/run.rs | 380 ++++ .../{sync_surgical.rs => sync/surgical.rs} | 2 +- src/cli/commands/sync/sync_tests.rs | 268 +++ src/cli/commands/timeline.rs | 10 +- src/cli/commands/who_tests.rs | 23 +- src/cli/mod.rs | 876 +--------- src/documents/extractor.rs | 1059 ----------- src/documents/extractor/common.rs | 80 + src/documents/extractor/discussions.rs | 216 +++ .../{ => extractor}/extractor_tests.rs | 0 src/documents/extractor/issues.rs | 110 ++ src/documents/extractor/mod.rs | 24 + src/documents/extractor/mrs.rs | 119 ++ src/documents/extractor/notes.rs | 514 ++++++ src/embedding/change_detector.rs | 2 +- src/embedding/chunks.rs | 177 ++ src/embedding/mod.rs | 5 +- src/embedding/pipeline.rs | 7 +- src/embedding/pipeline_tests.rs | 2 +- src/ingestion/discussions.rs | 2 +- src/ingestion/issues.rs | 2 +- src/ingestion/merge_requests.rs | 2 +- src/ingestion/mod.rs | 1 + src/ingestion/mr_discussions.rs | 2 +- src/ingestion/orchestrator.rs | 28 +- .../storage/events.rs} | 4 +- src/ingestion/storage/mod.rs | 4 + src/{core => ingestion/storage}/payloads.rs | 4 +- .../storage}/payloads_tests.rs | 0 .../storage/queue.rs} | 6 +- src/{core => ingestion/storage}/sync_run.rs | 6 +- .../storage}/sync_run_tests.rs | 0 src/lib.rs | 4 + src/search/vector.rs | 2 +- src/test_support.rs | 49 + .../collect.rs} | 4 +- .../timeline_expand.rs => timeline/expand.rs} | 2 +- src/timeline/mod.rs | 11 + .../timeline_seed.rs => timeline/seed.rs} | 4 +- .../timeline_collect_tests.rs | 4 +- .../timeline_expand_tests.rs | 0 src/{core => timeline}/timeline_seed_tests.rs | 0 src/{core/timeline.rs => timeline/types.rs} | 8 +- src/xref/mod.rs | 2 + src/{core => xref}/note_parser.rs | 4 +- src/{core => xref}/note_parser_tests.rs | 0 src/{core => xref}/references.rs | 4 +- src/{core => xref}/references_tests.rs | 0 tests/timeline_pipeline_tests.rs | 8 +- 78 files changed, 8704 insertions(+), 6973 deletions(-) create mode 100644 rust-toolchain.toml create mode 100644 src/app/dispatch.rs create mode 100644 src/app/errors.rs create mode 100644 src/app/robot_docs.rs create mode 100644 src/cli/args.rs create mode 100644 src/cli/commands/ingest/mod.rs create mode 100644 src/cli/commands/ingest/render.rs rename src/cli/commands/{ingest.rs => ingest/run.rs} (74%) delete mode 100644 src/cli/commands/list.rs create mode 100644 src/cli/commands/list/issues.rs rename src/cli/commands/{ => list}/list_tests.rs (96%) create mode 100644 src/cli/commands/list/mod.rs create mode 100644 src/cli/commands/list/mrs.rs create mode 100644 src/cli/commands/list/notes.rs create mode 100644 src/cli/commands/list/render_helpers.rs delete mode 100644 src/cli/commands/show.rs create mode 100644 src/cli/commands/show/issue.rs create mode 100644 src/cli/commands/show/mod.rs create mode 100644 src/cli/commands/show/mr.rs create mode 100644 src/cli/commands/show/render.rs create mode 100644 src/cli/commands/show/show_tests.rs delete mode 100644 src/cli/commands/sync.rs create mode 100644 src/cli/commands/sync/mod.rs create mode 100644 src/cli/commands/sync/render.rs create mode 100644 src/cli/commands/sync/run.rs rename src/cli/commands/{sync_surgical.rs => sync/surgical.rs} (99%) create mode 100644 src/cli/commands/sync/sync_tests.rs delete mode 100644 src/documents/extractor.rs create mode 100644 src/documents/extractor/common.rs create mode 100644 src/documents/extractor/discussions.rs rename src/documents/{ => extractor}/extractor_tests.rs (100%) create mode 100644 src/documents/extractor/issues.rs create mode 100644 src/documents/extractor/mod.rs create mode 100644 src/documents/extractor/mrs.rs create mode 100644 src/documents/extractor/notes.rs create mode 100644 src/embedding/chunks.rs rename src/{core/events_db.rs => ingestion/storage/events.rs} (98%) create mode 100644 src/ingestion/storage/mod.rs rename src/{core => ingestion/storage}/payloads.rs (97%) rename src/{core => ingestion/storage}/payloads_tests.rs (100%) rename src/{core/dependent_queue.rs => ingestion/storage/queue.rs} (98%) rename src/{core => ingestion/storage}/sync_run.rs (97%) rename src/{core => ingestion/storage}/sync_run_tests.rs (100%) create mode 100644 src/test_support.rs rename src/{core/timeline_collect.rs => timeline/collect.rs} (99%) rename src/{core/timeline_expand.rs => timeline/expand.rs} (98%) create mode 100644 src/timeline/mod.rs rename src/{core/timeline_seed.rs => timeline/seed.rs} (99%) rename src/{core => timeline}/timeline_collect_tests.rs (99%) rename src/{core => timeline}/timeline_expand_tests.rs (100%) rename src/{core => timeline}/timeline_seed_tests.rs (100%) rename src/{core/timeline.rs => timeline/types.rs} (98%) create mode 100644 src/xref/mod.rs rename src/{core => xref}/note_parser.rs (99%) rename src/{core => xref}/note_parser_tests.rs (100%) rename src/{core => xref}/references.rs (98%) rename src/{core => xref}/references_tests.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index ab82118..d4835ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ open = "5" reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] } +# Async runtime (asupersync migration candidate) +asupersync = { version = "0.2", features = ["tls", "tls-native-roots", "proc-macros"] } + # Async streaming for pagination async-stream = "0.3" futures = { version = "0.3", default-features = false, features = ["alloc"] } diff --git a/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md b/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md index 781c979..23c0f66 100644 --- a/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md +++ b/PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md @@ -1,425 +1,636 @@ # Proposed Code File Reorganization Plan -## Executive Summary +## 1. Scope, Audit Method, and Constraints -The codebase is 79 Rust source files / 46K lines across 7 top-level modules. Most modules (`gitlab/`, `embedding/`, `search/`, `documents/`, `ingestion/`) are well-organized. The pain points are: +This plan is based on a full audit of the `src/` tree (all 131 Rust files) plus integration tests in `tests/` that import `src` modules. -1. **`core/` is a grab-bag** — 22 files mixing infrastructure, domain logic, DB operations, and an entire timeline pipeline -2. **`main.rs` is 2713 lines** — ~30 handler functions that bridge CLI args to commands -3. **`cli/mod.rs` is 949 lines** — every clap argument struct is packed into one file -4. **Giant command files** — `who.rs` (6067 lines), `list.rs` (2931 lines) are unwieldy +What I audited: +- module/file inventory (`src/**.rs`) +- line counts and hotspot analysis +- crate-internal import graph (`use crate::...`) +- public API surface (public structs/enums/functions by file) +- command routing and re-export topology (`main.rs`, `lib.rs`, `cli/mod.rs`, `cli/commands/mod.rs`) +- cross-module coupling and test coupling -This plan is organized into **three tiers** based on impact-to-risk ratio. Tier 1 changes are "no-brainers" — they reduce confusion with minimal import churn. Tier 2 changes are valuable but involve more cross-cutting import updates. Tier 3 changes are "maybe later" — they'd be nice but the juice might not be worth the squeeze right now. +Constraints followed for this proposal: +- no implementation yet (plan only) +- keep nesting shallow and intuitive +- optimize for discoverability for humans and coding agents +- no compatibility shims as a long-term strategy +- every structural change includes explicit call-site update tracking --- -## Current Structure (Annotated) +## 2. Current State (Measured) -``` +### 2.1 Size by top-level module (`src/`) + +| Module | Files | Lines | Prod Files | Prod Lines | Test Files | Test Lines | +|---|---:|---:|---:|---:|---:|---:| +| `cli` | 41 | 29,131 | 37 | 23,068 | 4 | 6,063 | +| `core` | 39 | 12,493 | 27 | 7,599 | 12 | 4,894 | +| `ingestion` | 15 | 6,935 | 10 | 5,259 | 5 | 1,676 | +| `documents` | 6 | 3,657 | 4 | 1,749 | 2 | 1,908 | +| `gitlab` | 11 | 3,607 | 8 | 2,391 | 3 | 1,216 | +| `embedding` | 10 | 1,878 | 7 | 1,327 | 3 | 551 | +| `search` | 6 | 1,115 | 6 | 1,115 | 0 | 0 | +| `main.rs` | 1 | 3,744 | 1 | 3,744 | 0 | 0 | +| `lib.rs` | 1 | 9 | 1 | 9 | 0 | 0 | + +Total in `src/`: **131 files / 62,569 lines**. + +### 2.2 Largest production hotspots + +| File | Lines | Why it matters | +|---|---:|---| +| `src/main.rs` | 3,744 | Binary entrypoint is doing too much dispatch and formatting work | +| `src/cli/autocorrect.rs` | 1,865 | Large parsing/correction ruleset in one file | +| `src/ingestion/orchestrator.rs` | 1,753 | Multi-stage ingestion orchestration and persistence mixed together | +| `src/cli/commands/show.rs` | 1,544 | Issue/MR retrieval + rendering + JSON conversion all in one file | +| `src/cli/render.rs` | 1,482 | Theme, table layout, formatting utilities bundled together | +| `src/cli/commands/list.rs` | 1,383 | Issues + MRs + notes listing/query/printing in one file | +| `src/cli/mod.rs` | 1,268 | Clap root parser plus every args struct | +| `src/cli/commands/sync.rs` | 1,201 | Sync flow + human rendering + JSON output | +| `src/cli/commands/me/queries.rs` | 1,135 | Multiple query families and post-processing logic | +| `src/cli/commands/ingest.rs` | 1,116 | Ingest flow + dry-run + presentation concerns | +| `src/documents/extractor.rs` | 1,059 | Four document source extractors in one file | + +### 2.3 High-level dependency flow (top modules) + +Observed module coupling from imports: +- `cli -> core` (very heavy, 33 files) +- `cli -> documents/embedding/gitlab/ingestion/search` (command-dependent) +- `ingestion -> core` (12 files), `ingestion -> gitlab` (10 files) +- `search -> core` and `search -> embedding` +- `timeline` logic currently located under `core/*timeline*` but semantically acts as its own subsystem + +### 2.4 Structural pain points + +1. `main.rs` is overloaded with command handlers, robot output envelope types, clap error mapping, and domain invocation. +2. `cli/mod.rs` mixes root parser concerns with command-specific argument schemas. +3. `core/` still holds domain-specific subsystems (`timeline`, cross-reference extraction, ingestion persistence helpers) that are not truly "core infra". +4. Several large command files combine query/build/fetch/render/json responsibilities. +5. Test helper setup is duplicated heavily in large test files (`who_tests`, `list_tests`, `me_tests`). + +--- + +## 3. Reorganization Principles + +1. Keep top-level domains explicit: `cli`, `core` (infra), `gitlab`, `ingestion`, `documents`, `embedding`, `search`, plus extracted domain modules where justified. +2. Keep nesting shallow: max 2-3 levels in normal workflow paths. +3. Co-locate command-specific args/types/rendering with the command implementation. +4. Separate orchestration from formatting from data-access code. +5. Prefer module boundaries that map to runtime pipeline boundaries. +6. Make import paths reveal ownership directly. + +--- + +## 4. Proposed Target Structure (End State) + +```text src/ -├── main.rs (2713 lines) ← dispatch + ~30 handler functions + error helpers -├── lib.rs (9 lines) -├── cli/ -│ ├── mod.rs (949 lines) ← ALL clap arg structs crammed here -│ ├── autocorrect.rs (945 lines) -│ ├── progress.rs (92 lines) -│ ├── robot.rs (111 lines) -│ └── commands/ -│ ├── mod.rs (50 lines) — re-exports -│ ├── auth_test.rs -│ ├── count.rs (406 lines) -│ ├── doctor.rs (576 lines) -│ ├── drift.rs (642 lines) -│ ├── embed.rs -│ ├── generate_docs.rs (320 lines) -│ ├── ingest.rs (1064 lines) -│ ├── init.rs (174 lines) -│ ├── list.rs (2931 lines) ← handles issues, MRs, AND notes listing -│ ├── search.rs (418 lines) -│ ├── show.rs (1377 lines) -│ ├── stats.rs (505 lines) -│ ├── sync_status.rs (454 lines) -│ ├── sync.rs (576 lines) -│ ├── timeline.rs (488 lines) -│ └── who.rs (6067 lines) ← 5 sub-modes: expert, workload, active, overlap, reviews -├── core/ -│ ├── mod.rs (25 lines) -│ ├── backoff.rs ← retry logic (used by ingestion) -│ ├── config.rs (789 lines) ← configuration types -│ ├── db.rs (970 lines) ← connection + 22 migrations -│ ├── dependent_queue.rs (330 lines) ← job queue (used by ingestion orchestrator) -│ ├── error.rs (295 lines) ← error enum + exit codes -│ ├── events_db.rs (199 lines) ← resource event upserts (used by ingestion) -│ ├── lock.rs (228 lines) ← filesystem sync lock -│ ├── logging.rs (179 lines) ← tracing filter builders -│ ├── metrics.rs (566 lines) ← tracing-based stage timing -│ ├── note_parser.rs (563 lines) ← cross-ref extraction from note bodies -│ ├── paths.rs ← config/db/log file path resolution -│ ├── payloads.rs (204 lines) ← raw JSON payload storage -│ ├── project.rs (274 lines) ← fuzzy project resolution from DB -│ ├── references.rs (551 lines) ← entity cross-reference extraction -│ ├── shutdown.rs ← graceful shutdown via tokio signal -│ ├── sync_run.rs (218 lines) ← sync run recording to DB -│ ├── time.rs ← time conversion utilities -│ ├── timeline.rs (284 lines) ← timeline types + EntityRef -│ ├── timeline_collect.rs (695 lines) ← Stage 4: collect events from DB -│ ├── timeline_expand.rs (557 lines) ← Stage 3: expand via cross-refs -│ └── timeline_seed.rs (552 lines) ← Stage 1: FTS search seeding -├── documents/ ← well-organized, 3 focused files -├── embedding/ ← well-organized, 6 focused files -├── gitlab/ ← well-organized, with transformers/ subdir -├── ingestion/ ← well-organized, 8 focused files -└── search/ ← well-organized, 5 focused files + main.rs # thin binary entrypoint + lib.rs + + app/ # NEW: runtime dispatch/orchestration glue + mod.rs + dispatch.rs + errors.rs + robot_docs.rs + + cli/ + mod.rs # Cli + Commands only + args.rs # shared args structs used by Commands variants + render/ + mod.rs + format.rs + table.rs + theme.rs + autocorrect/ + mod.rs + flags.rs + enums.rs + fuzzy.rs + commands/ + mod.rs + list/ + mod.rs + issues.rs + mrs.rs + notes.rs + render.rs + show/ + mod.rs + issue.rs + mr.rs + render.rs + me/ # keep existing folder, retain split style + who/ # keep existing folder, retain split style + ingest/ + mod.rs + run.rs + dry_run.rs + render.rs + sync/ + mod.rs + run.rs + render.rs + surgical.rs + # smaller focused commands can stay single-file for now + + core/ # infra-only boundary after moves + mod.rs + backoff.rs + config.rs + cron.rs + cursor.rs + db.rs + error.rs + file_history.rs + lock.rs + logging.rs + metrics.rs + path_resolver.rs + paths.rs + project.rs + shutdown.rs + time.rs + trace.rs + + timeline/ # NEW: extracted domain subsystem + mod.rs + types.rs + seed.rs + expand.rs + collect.rs + + xref/ # NEW: extracted cross-reference subsystem + mod.rs + note_parser.rs + references.rs + + ingestion/ + mod.rs + issues.rs + merge_requests.rs + discussions.rs + mr_discussions.rs + mr_diffs.rs + dirty_tracker.rs + discussion_queue.rs + orchestrator/ + mod.rs + issues_flow.rs + mrs_flow.rs + resource_events.rs + closes_issues.rs + diff_jobs.rs + progress.rs + storage/ # NEW: ingestion-owned persistence helpers + mod.rs + payloads.rs # from core/payloads.rs + events.rs # from core/events_db.rs + queue.rs # from core/dependent_queue.rs + sync_run.rs # from core/sync_run.rs + + documents/ + mod.rs + extractor/ + mod.rs + issues.rs + mrs.rs + discussions.rs + notes.rs + common.rs + regenerator.rs + truncation.rs + + embedding/ + mod.rs + change_detector.rs + chunks.rs # merge chunk_ids.rs + chunking.rs + ollama.rs + pipeline.rs + similarity.rs + + gitlab/ + # mostly keep as-is (already coherent) + + search/ + # mostly keep as-is (already coherent) +``` + +Notes: +- `gitlab/` and `search/` are already cohesive and should largely remain unchanged. +- `who/` and `me/` command families are already split well relative to other commands. + +--- + +## 5. Detailed Change Plan (Phased) + +## Phase 1: Domain Boundary Extraction (lowest conceptual risk, high clarity gain) + +### 5.1 Extract timeline subsystem from `core` + +Move: +- `src/core/timeline.rs` -> `src/timeline/types.rs` +- `src/core/timeline_seed.rs` -> `src/timeline/seed.rs` +- `src/core/timeline_expand.rs` -> `src/timeline/expand.rs` +- `src/core/timeline_collect.rs` -> `src/timeline/collect.rs` +- add `src/timeline/mod.rs` + +Why: +- Timeline is a full pipeline domain (seed -> expand -> collect), not core infra. +- Improves discoverability for `lore timeline` and timeline tests. + +Calling-code updates required: +- `src/cli/commands/timeline.rs` + - `crate::core::timeline::*` -> `crate::timeline::*` + - `crate::core::timeline_seed::*` -> `crate::timeline::seed::*` + - `crate::core::timeline_expand::*` -> `crate::timeline::expand::*` + - `crate::core::timeline_collect::*` -> `crate::timeline::collect::*` +- `tests/timeline_pipeline_tests.rs` + - `lore::core::timeline*` imports -> `lore::timeline::*` +- internal references among moved files update from `crate::core::timeline` to `crate::timeline::types` +- `src/core/mod.rs`: remove `timeline*` module declarations +- `src/lib.rs`: add `pub mod timeline;` + +### 5.2 Extract cross-reference subsystem from `core` + +Move: +- `src/core/note_parser.rs` -> `src/xref/note_parser.rs` +- `src/core/references.rs` -> `src/xref/references.rs` +- add `src/xref/mod.rs` + +Why: +- Cross-reference extraction is a domain subsystem feeding ingestion and timeline. +- Current placement in `core/` obscures data flow. + +Calling-code updates required: +- `src/ingestion/orchestrator.rs` + - `crate::core::references::*` -> `crate::xref::references::*` + - `crate::core::note_parser::*` -> `crate::xref::note_parser::*` +- `src/core/mod.rs`: remove `note_parser` and `references` +- `src/lib.rs`: add `pub mod xref;` +- tests referencing old paths update to `crate::xref::*` + +### 5.3 Move ingestion-owned persistence helpers out of `core` + +Move: +- `src/core/payloads.rs` -> `src/ingestion/storage/payloads.rs` +- `src/core/events_db.rs` -> `src/ingestion/storage/events.rs` +- `src/core/dependent_queue.rs` -> `src/ingestion/storage/queue.rs` +- `src/core/sync_run.rs` -> `src/ingestion/storage/sync_run.rs` +- add `src/ingestion/storage/mod.rs` + +Why: +- These files primarily support ingestion/sync runtime behavior and ingestion persistence. +- Consolidates ingestion runtime + ingestion storage into one domain area. + +Calling-code updates required: +- `src/ingestion/discussions.rs`, `issues.rs`, `merge_requests.rs`, `mr_discussions.rs` + - `core::payloads::*` -> `ingestion::storage::payloads::*` +- `src/ingestion/orchestrator.rs` + - `core::dependent_queue::*` -> `ingestion::storage::queue::*` + - `core::events_db::*` -> `ingestion::storage::events::*` +- `src/main.rs` + - `core::dependent_queue::release_all_locked_jobs` -> `ingestion::storage::queue::release_all_locked_jobs` + - `core::sync_run::SyncRunRecorder` -> `ingestion::storage::sync_run::SyncRunRecorder` +- `src/cli/commands/count.rs` + - `core::events_db::*` -> `ingestion::storage::events::*` +- `src/cli/commands/sync_surgical.rs` + - `core::sync_run::SyncRunRecorder` -> `ingestion::storage::sync_run::SyncRunRecorder` +- `src/core/mod.rs`: remove moved modules +- `src/ingestion/mod.rs`: export `pub mod storage;` + +--- + +## Phase 2: CLI Structure Cleanup (high dev ergonomics impact) + +### 5.4 Split `cli/mod.rs` responsibilities + +Current: +- root parser (`Cli`, `Commands`) +- all args structs (`IssuesArgs`, `WhoArgs`, `MeArgs`, etc.) + +Proposed: +- `src/cli/mod.rs`: only `Cli`, `Commands`, top-level parser behavior +- `src/cli/args.rs`: all args structs and command-local enums (`CronAction`, `TokenAction`) + +Why: +- keeps parser root small and readable +- one canonical place for args schemas + +Calling-code updates required: +- `src/main.rs` + - `use lore::cli::{..., WhoArgs, ...}` -> `use lore::cli::args::{...}` (or re-export from `cli/mod.rs`) +- `src/cli/commands/who/mod.rs` + - `use crate::cli::WhoArgs;` -> `use crate::cli::args::WhoArgs;` +- `src/cli/commands/me/mod.rs` + - `use crate::cli::MeArgs;` -> `use crate::cli::args::MeArgs;` + +### 5.5 Make `main.rs` thin by moving dispatch logic to `app/` + +Proposed splits from `main.rs`: +- `app/dispatch.rs`: all `handle_*` command handlers +- `app/errors.rs`: clap error mapping, correction warning formatting +- `app/robot_docs.rs`: robot docs schema/data envelope generation +- keep `main.rs`: startup, logging init, parse, delegate to dispatcher + +Why: +- reduces entrypoint complexity and improves testability of dispatch behavior +- isolates robot docs machinery from runtime bootstrapping + +Calling-code updates required: +- `main.rs`: replace direct handler function definitions with calls into `app::*` +- `lib.rs`: add `pub mod app;` if shared imports needed by tests + +--- + +## Phase 3: Split Large Command Files by Responsibility + +### 5.6 Split `cli/commands/list.rs` + +Proposed: +- `commands/list/issues.rs` (issue queries + issue output) +- `commands/list/mrs.rs` (MR queries + MR output) +- `commands/list/notes.rs` (note queries + note output) +- `commands/list/render.rs` (shared formatting helpers) +- `commands/list/mod.rs` (public API and re-exports) + +Why: +- list concerns are already logically tripartite +- better locality for bugfixes and feature additions + +Calling-code updates required: +- `src/cli/commands/mod.rs`: import module folder and re-export unchanged API names +- `src/main.rs`: ideally no change if `commands/mod.rs` re-exports remain stable + +### 5.7 Split `cli/commands/show.rs` + +Proposed: +- `commands/show/issue.rs` +- `commands/show/mr.rs` +- `commands/show/render.rs` +- `commands/show/mod.rs` + +Why: +- issue and MR detail assembly have separate SQL and shape logic +- rendering concerns can be isolated from data retrieval + +Calling-code updates required: +- `src/cli/commands/mod.rs` re-exports preserved (`run_show_issue`, `run_show_mr`, printers) +- `src/main.rs` remains stable if re-exports preserved + +### 5.8 Split `cli/commands/ingest.rs` and `cli/commands/sync.rs` + +Proposed: +- `commands/ingest/run.rs`, `dry_run.rs`, `render.rs`, `mod.rs` +- `commands/sync/run.rs`, `render.rs`, `surgical.rs`, `mod.rs` + +Why: +- orchestration, preview generation, and output rendering are currently intertwined +- surgical sync is semantically part of sync command family + +Calling-code updates required: +- update `src/cli/commands/mod.rs` exports +- update `src/cli/commands/sync_surgical.rs` path if merged into `commands/sync/surgical.rs` +- no CLI UX changes expected if external API names remain + +### 5.9 Split `documents/extractor.rs` + +Proposed: +- `documents/extractor/issues.rs` +- `documents/extractor/mrs.rs` +- `documents/extractor/discussions.rs` +- `documents/extractor/notes.rs` +- `documents/extractor/common.rs` +- `documents/extractor/mod.rs` + +Why: +- extractor currently contains four independent source-type extraction paths +- per-source unit tests become easier to target + +Calling-code updates required: +- `src/documents/mod.rs` re-export surface remains stable +- `src/documents/regenerator.rs` imports update only if internal re-export paths change + +--- + +## Phase 4: Opportunistic Consolidations + +### 5.10 Merge tiny embedding chunk helpers + +Merge: +- `src/embedding/chunk_ids.rs` +- `src/embedding/chunking.rs` +- into `src/embedding/chunks.rs` + +Why: +- both represent one conceptual concern: chunk partitioning and chunk identity mapping +- avoids tiny-file scattering + +Calling-code updates required: +- `src/embedding/pipeline.rs` +- `src/embedding/change_detector.rs` +- `src/search/vector.rs` +- `src/embedding/mod.rs` exports + +### 5.11 Test helper de-duplication + +Add a shared test support module for repeated DB fixture setup currently duplicated in: +- `src/cli/commands/who_tests.rs` +- `src/cli/commands/list_tests.rs` +- `src/cli/commands/me/me_tests.rs` +- multiple `core/*_tests.rs` + +Why: +- lower maintenance cost and fewer fixture drift bugs + +Calling-code updates required: +- test-only imports in affected files + +--- + +## 6. File-Level Recommendation Matrix + +Legend: +- `KEEP`: structure is already coherent +- `MOVE`: relocate without major logic split +- `SPLIT`: divide into focused files/modules +- `MERGE`: consolidate tiny related files + +### 6.1 `core/` + +- `backoff.rs` -> KEEP +- `config.rs` -> KEEP (large but cohesive) +- `cron.rs` -> KEEP +- `cursor.rs` -> KEEP +- `db.rs` -> KEEP +- `dependent_queue.rs` -> MOVE to `ingestion/storage/queue.rs` +- `error.rs` -> KEEP +- `events_db.rs` -> MOVE to `ingestion/storage/events.rs` +- `file_history.rs` -> KEEP +- `lock.rs` -> KEEP +- `logging.rs` -> KEEP +- `metrics.rs` -> KEEP +- `note_parser.rs` -> MOVE to `xref/note_parser.rs` +- `path_resolver.rs` -> KEEP +- `paths.rs` -> KEEP +- `payloads.rs` -> MOVE to `ingestion/storage/payloads.rs` +- `project.rs` -> KEEP +- `references.rs` -> MOVE to `xref/references.rs` +- `shutdown.rs` -> KEEP +- `sync_run.rs` -> MOVE to `ingestion/storage/sync_run.rs` +- `time.rs` -> KEEP +- `timeline.rs`, `timeline_seed.rs`, `timeline_expand.rs`, `timeline_collect.rs` -> MOVE to `timeline/` +- `trace.rs` -> KEEP + +### 6.2 `cli/` + +- `mod.rs` -> SPLIT (`mod.rs` + `args.rs`) +- `autocorrect.rs` -> SPLIT into `autocorrect/` submodules +- `render.rs` -> SPLIT into `render/` submodules +- `commands/list.rs` -> SPLIT into `commands/list/` +- `commands/show.rs` -> SPLIT into `commands/show/` +- `commands/ingest.rs` -> SPLIT into `commands/ingest/` +- `commands/sync.rs` + `commands/sync_surgical.rs` -> SPLIT/MERGE into `commands/sync/` +- `commands/me/*` -> KEEP (already good shape) +- `commands/who/*` -> KEEP (already good shape) +- small focused commands (`auth_test`, `embed`, `trace`, etc.) -> KEEP + +### 6.3 `documents/` + +- `extractor.rs` -> SPLIT into extractor folder +- `regenerator.rs` -> KEEP +- `truncation.rs` -> KEEP + +### 6.4 `embedding/` + +- `change_detector.rs` -> KEEP +- `chunk_ids.rs` + `chunking.rs` -> MERGE into `chunks.rs` +- `ollama.rs` -> KEEP +- `pipeline.rs` -> KEEP for now (already a pipeline-centric file) +- `similarity.rs` -> KEEP + +### 6.5 `gitlab/`, `search/` + +- KEEP as-is except minor internal refactors only when touched by feature work + +--- + +## 7. Import/Call-Site Impact Tracker (must-update list) + +This section tracks files that must be updated when moves happen to avoid broken builds. + +### 7.1 For timeline extraction + +Must update: +- `src/cli/commands/timeline.rs` +- `tests/timeline_pipeline_tests.rs` +- moved timeline module internals (`seed`, `expand`, `collect`) +- `src/core/mod.rs` +- `src/lib.rs` + +### 7.2 For xref extraction + +Must update: +- `src/ingestion/orchestrator.rs` (all `core::references` and `core::note_parser` paths) +- tests importing moved modules +- `src/core/mod.rs` +- `src/lib.rs` + +### 7.3 For ingestion storage move + +Must update: +- `src/ingestion/discussions.rs` +- `src/ingestion/issues.rs` +- `src/ingestion/merge_requests.rs` +- `src/ingestion/mr_discussions.rs` +- `src/ingestion/orchestrator.rs` +- `src/cli/commands/count.rs` +- `src/cli/commands/sync_surgical.rs` +- `src/main.rs` +- `src/core/mod.rs` +- `src/ingestion/mod.rs` + +### 7.4 For CLI args split + +Must update: +- `src/main.rs` +- `src/cli/commands/who/mod.rs` +- `src/cli/commands/me/mod.rs` +- any command file importing args directly from `crate::cli::*Args` + +### 7.5 For command file splits + +Must update: +- `src/cli/commands/mod.rs` re-exports +- tests that import command internals by file/module path +- `src/main.rs` only if re-export names change (recommended: keep names stable) + +--- + +## 8. Execution Strategy (Safe Order) + +Recommended order: +1. Phase 1 (`timeline`, `xref`, `ingestion/storage`) with no behavior changes. +2. Phase 2 (`cli/mod.rs` split, `main.rs` thinning) while preserving command signatures. +3. Phase 3 (`list`, `show`, `ingest`, `sync`, `extractor` splits). +4. Phase 4 opportunistic merges and test helper dedupe. + +For each phase: +- complete file moves/splits and import rewrites in one cohesive change +- run quality gates +- only then proceed to next phase + +--- + +## 9. Verification and Non-Regression Checklist + +After each phase, run: + +```bash +cargo check --all-targets +cargo clippy --all-targets -- -D warnings +cargo fmt --check +cargo test +cargo test -- --nocapture +``` + +Targeted suites to run when relevant: +- timeline moves: `cargo test timeline_pipeline_tests` +- who/me/list splits: `cargo test who_tests`, `cargo test list_tests`, `cargo test me_tests` +- ingestion storage moves: `cargo test ingestion` + +Before each commit, run UBS on changed files: + +```bash +ubs ``` --- -## Tier 1: No-Brainers (Do First) +## 10. Risks and Mitigations -### 1.1 Extract `timeline/` from `core/` +Primary risks: +1. Import path churn causing compile errors. +2. Accidental visibility changes (`pub`/`pub(crate)`) during file splits. +3. Re-export drift breaking `main.rs` or tests. +4. Behavioral drift from mixed refactor + logic changes. -**What:** Move the 4 timeline files into their own top-level module `src/timeline/`. - -**Current location:** -- `core/timeline.rs` (284 lines) — types: `EntityRef`, `ExpandedEntityRef`, `TimelineEvent`, `TimelineEventType`, etc. -- `core/timeline_seed.rs` (552 lines) — Stage 1: FTS-based seeding -- `core/timeline_expand.rs` (557 lines) — Stage 3: cross-reference expansion -- `core/timeline_collect.rs` (695 lines) — Stage 4: event collection from DB - -**New structure:** -``` -src/timeline/ -├── mod.rs ← types (from timeline.rs) + re-exports -├── seed.rs ← from timeline_seed.rs -├── expand.rs ← from timeline_expand.rs -└── collect.rs ← from timeline_collect.rs -``` - -**Rationale:** These 4 files form a cohesive 5-stage pipeline (SEED→HYDRATE→EXPAND→COLLECT→RENDER). They have nothing to do with "core" infrastructure like `db.rs`, `config.rs`, or `error.rs`. They only import from `core::error`, `core::time`, and `search::fts` — all of which remain accessible via `crate::core::*` and `crate::search::*` after the move. - -**Import changes needed:** -- `cli/commands/timeline.rs`: `use crate::core::timeline::*` → `use crate::timeline::*`, same for `timeline_seed`, `timeline_expand`, `timeline_collect` -- `core/mod.rs`: remove the 4 `pub mod timeline*` lines -- `lib.rs`: add `pub mod timeline;` - -**Risk: LOW** — Only 1 consumer (`cli/commands/timeline.rs`) + internal cross-references between the 4 files. +Mitigations: +- refactor-only phases (no feature changes) +- keep public API names stable during directory reshapes +- preserve command re-exports in `cli/commands/mod.rs` +- run full quality gates after each phase --- -### 1.2 Extract `xref/` (cross-reference extraction) from `core/` +## 11. Recommendation -**What:** Move `note_parser.rs` and `references.rs` into `src/xref/`. +Start with **Phase 1 only** in the first implementation pass. It yields major clarity gains with relatively constrained blast radius. -**Current location:** -- `core/note_parser.rs` (563 lines) — parses note bodies for "mentioned in group/repo#123" patterns, persists to `note_cross_references` table -- `core/references.rs` (551 lines) — extracts entity references from state events and closing MRs, writes to `entity_references` table +If Phase 1 lands cleanly, proceed with Phase 2. Phase 3 should be done in smaller PR-sized chunks (`list` first, then `show`, then `ingest/sync`, then `documents/extractor`). -**New structure:** -``` -src/xref/ -├── mod.rs ← re-exports -├── note_parser.rs ← from core/note_parser.rs -└── references.rs ← from core/references.rs -``` - -**Rationale:** These files implement a specific domain concept — extracting and persisting cross-references between issues and MRs. They are not "core infrastructure." They're consumed by `ingestion/orchestrator.rs` for the cross-reference extraction phase, and the data they produce is consumed by the timeline pipeline. Putting them in their own module makes the data flow clearer: `ingestion → xref → timeline`. - -**Import changes needed:** -- `ingestion/orchestrator.rs`: `use crate::core::references::*` → `use crate::xref::references::*` -- `ingestion/orchestrator.rs`: `use crate::core::note_parser::*` (if used directly — needs verification) → `use crate::xref::*` -- `core/mod.rs`: remove `pub mod note_parser; pub mod references;` -- `lib.rs`: add `pub mod xref;` -- Internal: the files use `super::error::Result` and `super::time::now_ms` which become `crate::core::error::Result` and `crate::core::time::now_ms` - -**Risk: LOW** — 2-3 consumers at most. The files already use `super::` internally which just needs updating to `crate::core::`. - ---- - -## Tier 2: Good Improvements (Do After Tier 1) - -### 2.1 Group ingestion-adjacent DB operations - -**What:** Move `events_db.rs`, `dependent_queue.rs`, `payloads.rs`, and `sync_run.rs` from `core/` into `ingestion/` since they exclusively serve the ingestion pipeline. - -**Current consumers:** -- `events_db.rs` → only used by `cli/commands/count.rs` (for event counts) -- `dependent_queue.rs` → only used by `ingestion/orchestrator.rs` and `main.rs` (to release locked jobs) -- `payloads.rs` → only used by `ingestion/discussions.rs`, `ingestion/issues.rs`, `ingestion/merge_requests.rs`, `ingestion/mr_discussions.rs` -- `sync_run.rs` → only used by `cli/commands/sync.rs` and `cli/commands/sync_status.rs` - -**New structure:** -``` -src/ingestion/ -├── (existing files...) -├── events_db.rs ← from core/events_db.rs -├── dependent_queue.rs ← from core/dependent_queue.rs -├── payloads.rs ← from core/payloads.rs -└── sync_run.rs ← from core/sync_run.rs -``` - -**Rationale:** All 4 files exist to support the ingestion pipeline: -- `events_db.rs` upserts resource state/label/milestone events fetched during ingestion -- `dependent_queue.rs` manages the job queue that drives incremental discussion fetching -- `payloads.rs` stores the raw JSON payloads fetched from GitLab -- `sync_run.rs` records when syncs start/finish and their metrics - -When you're looking for "how does ingestion work?", you'd naturally look in `ingestion/`. Having these scattered in `core/` requires knowing the hidden dependency. - -**Import changes needed:** -- `events_db.rs`: 1 consumer in `cli/commands/count.rs` changes from `crate::core::events_db` → `crate::ingestion::events_db` -- `dependent_queue.rs`: 2 consumers — `ingestion/orchestrator.rs` (becomes `super::dependent_queue`) and `main.rs` -- `payloads.rs`: 4 consumers in `ingestion/*.rs` (become `super::payloads`) -- `sync_run.rs`: 2 consumers in `cli/commands/sync.rs` and `sync_status.rs` -- Internal references change from `super::error` / `super::time` to `crate::core::error` / `crate::core::time` - -**Risk: MEDIUM** — More import changes, but all straightforward. The internal `super::` references need the most attention. - -**Alternatively:** If moving feels like too much churn, a lighter option is to create `core/ingestion_db.rs` that re-exports from these 4 files, making the grouping visible without moving files. But I think the move is cleaner. - ---- - -### 2.2 Split `cli/mod.rs` — move arg structs to their command files - -**What:** Move each `*Args` struct from `cli/mod.rs` into the corresponding `cli/commands/*.rs` file. Keep `Cli` struct, `Commands` enum, and `detect_robot_mode_from_env()` in `cli/mod.rs`. - -**Currently `cli/mod.rs` (949 lines) contains:** -- `Cli` struct (81 lines) — the root clap parser -- `Commands` enum (193 lines) — all subcommand variants -- `IssuesArgs` (86 lines) → move to `commands/list.rs` or stay near issues handling -- `MrsArgs` (93 lines) → move to `commands/list.rs` or stay near MRs handling -- `NotesArgs` (99 lines) → move to `commands/list.rs` -- `IngestArgs` (33 lines) → move to `commands/ingest.rs` -- `StatsArgs` (19 lines) → move to `commands/stats.rs` -- `SearchArgs` (58 lines) → move to `commands/search.rs` -- `GenerateDocsArgs` (9 lines) → move to `commands/generate_docs.rs` -- `SyncArgs` (39 lines) → move to `commands/sync.rs` -- `EmbedArgs` (15 lines) → move to `commands/embed.rs` -- `TimelineArgs` (53 lines) → move to `commands/timeline.rs` -- `WhoArgs` (76 lines) → move to `commands/who.rs` -- `CountArgs` (9 lines) → move to `commands/count.rs` - -**After refactoring, `cli/mod.rs` shrinks to ~300 lines** (just `Cli` + `Commands` + the inlined variants like `Init`, `Drift`, `Backup`, `Reset`). - -**Rationale:** When adding a new flag to the `who` command, you currently have to edit `cli/mod.rs` (the args struct), `cli/commands/who.rs` (the implementation), and `main.rs` (the dispatch). If the args struct lives in `commands/who.rs`, you only need two files. This is the standard pattern in mature clap-based Rust CLIs. - -**Import changes needed:** -- `main.rs` currently does `use lore::cli::{..., WhoArgs, ...}` — these would become `use lore::cli::commands::{..., WhoArgs, ...}` or the `commands/mod.rs` re-exports them -- Each `commands/*.rs` gets its own `#[derive(Parser)]` struct -- `Commands` enum in `cli/mod.rs` keeps using the types but imports from `commands::*` - -**Risk: MEDIUM** — Lots of `use` path changes in `main.rs`, but purely mechanical. No logic changes. - ---- - -## Tier 3: Consider Later - -### 3.1 Split `main.rs` (2713 lines) - -**The problem:** `main.rs` contains `main()`, ~30 `handle_*` functions, error handling, clap error formatting, fuzzy command matching, and the `robot-docs` JSON manifest (a 400+ line inline JSON literal). - -**Possible approach:** -- Extract `handle_*` functions into `cli/dispatch.rs` (the routing layer) -- Extract error handling into `cli/errors.rs` -- Extract `handle_robot_docs` + the JSON manifest into `cli/robot_docs.rs` -- Keep `main()` in `main.rs` at ~150 lines (just the tracing setup + dispatch call) - -**Why Tier 3:** This is the messiest split. The handler functions depend on the `cli::commands::*` functions AND the `cli::robot::*` helpers AND direct `std::process::exit` calls. Making this work cleanly requires careful thought about the error boundary between `main.rs` (binary) and `lib.rs` (library). - -**Risk: HIGH** — Every handler function touches `robot_mode`, constructs its own timer, opens the DB, and manages error display. The boilerplate is high but consistent, so splitting would just move it around without reducing complexity. - ---- - -### 3.2 Split `cli/commands/who.rs` (6067 lines) - -**The problem:** This file implements 5 distinct modes (expert, workload, active, overlap, reviews), each with its own query, scoring model, and output formatting. It also includes the time-decay scoring model (~500 lines) and per-MR detail breakdown logic. - -**Possible split:** -``` -src/cli/commands/who/ -├── mod.rs ← WhoRun dispatcher, shared types -├── expert.rs ← expert mode (path-based file expertise lookup) -├── workload.rs ← workload mode (user's assigned issues/MRs) -├── active.rs ← active discussions mode -├── overlap.rs ← file overlap between users -├── reviews.rs ← review pattern analysis -└── scoring.rs ← time-decay expert scoring model -``` - -**Why Tier 3:** The 5 modes share many helper functions, database connection patterns, and output formatting logic. Splitting would require carefully identifying the shared helpers and deciding where they live. The file is big but internally consistent — the modes use a shared dispatcher pattern and common types. - ---- - -### 3.3 Split `cli/commands/list.rs` (2931 lines) - -**The problem:** This file handles issue listing, MR listing, AND note listing — three related but distinct operations with separate query builders, output formatters, and test suites. - -**Possible split:** -``` -src/cli/commands/ -├── list_issues.rs ← issue listing + query builder -├── list_mrs.rs ← MR listing + query builder -├── list_notes.rs ← note listing + query builder -└── list.rs ← shared types (ListFilters, etc.) + re-exports -``` - -**Why Tier 3:** Same issue as `who.rs` — the three listing modes share query building patterns, field selection logic, and sorting code. Splitting requires identifying and extracting the shared pieces first. - ---- - -## Files NOT Recommended to Move - -These files belong exactly where they are: - -| File | Why it belongs in `core/` | -|------|--------------------------| -| `config.rs` | Config types used by nearly everything | -| `db.rs` | Database connection + migrations — foundational | -| `error.rs` | Error types used by every module | -| `paths.rs` | File path resolution — infrastructure | -| `logging.rs` | Tracing setup — infrastructure | -| `lock.rs` | Filesystem sync lock — infrastructure | -| `shutdown.rs` | Graceful shutdown signal — infrastructure | -| `backoff.rs` | Retry math — infrastructure | -| `time.rs` | Time conversion — used everywhere | -| `metrics.rs` | Tracing metrics layer — infrastructure | -| `project.rs` | Fuzzy project resolution — used by 8+ consumers across modules | - -These files are legitimate "core infrastructure" used across multiple modules. Moving them would create import churn with no clarity gain. - ---- - -## Files NOT Recommended to Split/Merge - -| File | Why leave it alone | -|------|-------------------| -| `documents/extractor.rs` (2341 lines) | One cohesive extractor per entity type — the size comes from per-type formatting logic, not mixed concerns | -| `ingestion/orchestrator.rs` (1703 lines) | Single orchestration flow — splitting would scatter the pipeline | -| `gitlab/graphql.rs` (1293 lines) | GraphQL client with adaptive paging — cohesive | -| `gitlab/client.rs` (851 lines) | REST client with all endpoints — cohesive | -| `cli/autocorrect.rs` (945 lines) | Correction registry + fuzzy matching — splitting gains nothing | - ---- - -## Proposed Final Structure (Tiers 1+2) - -``` -src/ -├── main.rs (2713 lines — unchanged for now) -├── lib.rs (adds: pub mod timeline; pub mod xref;) -├── cli/ -│ ├── mod.rs (~300 lines — Cli + Commands only, args moved out) -│ ├── autocorrect.rs (unchanged) -│ ├── progress.rs (unchanged) -│ ├── robot.rs (unchanged) -│ └── commands/ -│ ├── mod.rs (re-exports + WhoArgs, IssuesArgs, etc.) -│ ├── (all existing files — unchanged but with args structs moved in) -│ └── ... -├── core/ (slimmed: 14 files → infrastructure only) -│ ├── mod.rs -│ ├── backoff.rs -│ ├── config.rs -│ ├── db.rs -│ ├── error.rs -│ ├── lock.rs -│ ├── logging.rs -│ ├── metrics.rs -│ ├── paths.rs -│ ├── project.rs -│ ├── shutdown.rs -│ └── time.rs -├── timeline/ (NEW — extracted from core/) -│ ├── mod.rs (types from core/timeline.rs) -│ ├── seed.rs (from core/timeline_seed.rs) -│ ├── expand.rs (from core/timeline_expand.rs) -│ └── collect.rs (from core/timeline_collect.rs) -├── xref/ (NEW — extracted from core/) -│ ├── mod.rs -│ ├── note_parser.rs (from core/note_parser.rs) -│ └── references.rs (from core/references.rs) -├── ingestion/ (gains 4 files from core/) -│ ├── (existing files...) -│ ├── events_db.rs (from core/events_db.rs) -│ ├── dependent_queue.rs (from core/dependent_queue.rs) -│ ├── payloads.rs (from core/payloads.rs) -│ └── sync_run.rs (from core/sync_run.rs) -├── documents/ (unchanged) -├── embedding/ (unchanged) -├── gitlab/ (unchanged) -└── search/ (unchanged) -``` - ---- - -## Import Change Tracking - -### Tier 1.1: Timeline extraction - -| Consumer file | Old import | New import | -|---------------|-----------|------------| -| `cli/commands/timeline.rs:10-15` | `crate::core::timeline::*` | `crate::timeline::*` | -| `cli/commands/timeline.rs:13` | `crate::core::timeline_collect::collect_events` | `crate::timeline::collect_events` (or `crate::timeline::collect::collect_events`) | -| `cli/commands/timeline.rs:14` | `crate::core::timeline_expand::expand_timeline` | `crate::timeline::expand_timeline` | -| `cli/commands/timeline.rs:15` | `crate::core::timeline_seed::seed_timeline` | `crate::timeline::seed_timeline` | -| `core/timeline_seed.rs:7-8` | `super::timeline::*` | `super::*` (or `crate::timeline::*` depending on structure) | -| `core/timeline_expand.rs:6` | `super::timeline::*` | `super::*` | -| `core/timeline_collect.rs:4` | `super::timeline::*` | `super::*` | -| `core/timeline_seed.rs:8` | `crate::search::*` | `crate::search::*` (no change) | -| `core/timeline_seed.rs:6-7` | `super::error::Result` | `crate::core::error::Result` | -| `core/timeline_expand.rs:5` | `super::error::Result` | `crate::core::error::Result` | -| `core/timeline_collect.rs:3` | `super::error::*` | `crate::core::error::*` | - -### Tier 1.2: Cross-reference extraction - -| Consumer file | Old import | New import | -|---------------|-----------|------------| -| `ingestion/orchestrator.rs:10-12` | `crate::core::references::*` | `crate::xref::references::*` | -| `core/note_parser.rs:7-8` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` | -| `core/references.rs:4-5` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` | - -### Tier 2.1: Ingestion-adjacent DB ops - -| Consumer file | Old import | New import | -|---------------|-----------|------------| -| `cli/commands/count.rs:9` | `crate::core::events_db::*` | `crate::ingestion::events_db::*` | -| `ingestion/orchestrator.rs:6-8` | `crate::core::dependent_queue::*` | `super::dependent_queue::*` | -| `main.rs:37` | `crate::core::dependent_queue::release_all_locked_jobs` | `crate::ingestion::dependent_queue::release_all_locked_jobs` | -| `ingestion/discussions.rs:7` | `crate::core::payloads::*` | `super::payloads::*` | -| `ingestion/issues.rs:9` | `crate::core::payloads::*` | `super::payloads::*` | -| `ingestion/merge_requests.rs:8` | `crate::core::payloads::*` | `super::payloads::*` | -| `ingestion/mr_discussions.rs:7` | `crate::core::payloads::*` | `super::payloads::*` | -| `cli/commands/sync.rs` | (uses `crate::core::sync_run::*`) | `crate::ingestion::sync_run::*` | -| `cli/commands/sync_status.rs` | (uses `crate::core::sync_run::*` or `crate::core::metrics::*`) | check and update | -| Internal: `events_db.rs:4-5` | `super::error::*`, `super::time::*` | `crate::core::error::*`, `crate::core::time::*` | -| Internal: `dependent_queue.rs:5-6` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` | -| Internal: `payloads.rs:9-10` | `super::error::Result`, `super::time::now_ms` | `crate::core::error::Result`, `crate::core::time::now_ms` | -| Internal: `sync_run.rs:2-4` | `super::error::*`, `super::metrics::*`, `super::time::*` | `crate::core::error::*`, `crate::core::metrics::*`, `crate::core::time::*` | - ---- - -## Execution Order - -1. **Tier 1.1** — Extract timeline → `src/timeline/` (LOW risk, 1 consumer) -2. **Tier 1.2** — Extract xref → `src/xref/` (LOW risk, 1-2 consumers) -3. **Cargo check + clippy + test** after each tier -4. **Tier 2.1** — Move ingestion DB ops (MEDIUM risk, more consumers) -5. **Cargo check + clippy + test** -6. **Tier 2.2** — Split `cli/mod.rs` args (MEDIUM risk, mostly mechanical) -7. **Cargo check + clippy + test + fmt** - -Each tier should be its own commit for easy rollback. - ---- - -## What This Achieves - -**Before:** A developer looking at `core/` sees 22 files and has to mentally sort "infrastructure vs. domain logic vs. pipeline stage." The timeline pipeline is invisible unless you know to look in `core/`. - -**After:** -- `core/` has 12 files, all clearly infrastructure (db, config, error, paths, logging, lock, shutdown, backoff, time, metrics, project) -- `timeline/` is a discoverable first-class module showing the 5-stage pipeline -- `xref/` makes the cross-reference extraction domain visible -- `ingestion/` contains everything related to data fetching: the orchestrator, entity ingestors, AND their supporting DB operations -- `cli/mod.rs` is lean — just the top-level Cli struct and Commands enum - -A new developer (or coding agent) can now answer "where is the timeline code?" → `src/timeline/`, "where is ingestion?" → `src/ingestion/`, "where is cross-reference extraction?" → `src/xref/`, without needing institutional knowledge. +No code/file moves have been executed yet; this document is the proposal for review and approval. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a9c39f2 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2026-03-01" diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs new file mode 100644 index 0000000..c14ccbc --- /dev/null +++ b/src/app/dispatch.rs @@ -0,0 +1,3 @@ +include!("errors.rs"); +include!("handlers.rs"); +include!("robot_docs.rs"); diff --git a/src/app/errors.rs b/src/app/errors.rs new file mode 100644 index 0000000..8859618 --- /dev/null +++ b/src/app/errors.rs @@ -0,0 +1,478 @@ +#[derive(Serialize)] +struct FallbackErrorOutput { + error: FallbackError, +} + +#[derive(Serialize)] +struct FallbackError { + code: String, + message: String, +} + +fn handle_error(e: Box, robot_mode: bool) -> ! { + if let Some(gi_error) = e.downcast_ref::() { + if robot_mode { + let output = RobotErrorOutput::from(gi_error); + eprintln!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|_| { + let fallback = FallbackErrorOutput { + error: FallbackError { + code: "INTERNAL_ERROR".to_string(), + message: gi_error.to_string(), + }, + }; + serde_json::to_string(&fallback) + .unwrap_or_else(|_| r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#.to_string()) + }) + ); + std::process::exit(gi_error.exit_code()); + } else { + eprintln!(); + eprintln!( + " {} {}", + Theme::error().render(Icons::error()), + Theme::error().bold().render(&gi_error.to_string()) + ); + if let Some(suggestion) = gi_error.suggestion() { + eprintln!(); + eprintln!(" {suggestion}"); + } + let actions = gi_error.actions(); + if !actions.is_empty() { + eprintln!(); + for action in &actions { + eprintln!( + " {} {}", + Theme::dim().render("\u{2192}"), + Theme::bold().render(action) + ); + } + } + eprintln!(); + std::process::exit(gi_error.exit_code()); + } + } + + if robot_mode { + let output = FallbackErrorOutput { + error: FallbackError { + code: "INTERNAL_ERROR".to_string(), + message: e.to_string(), + }, + }; + eprintln!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|_| { + r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"# + .to_string() + }) + ); + } else { + eprintln!(); + eprintln!( + " {} {}", + Theme::error().render(Icons::error()), + Theme::error().bold().render(&e.to_string()) + ); + eprintln!(); + } + 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, + } + + let teaching: Vec = 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!( + "{} {}", + Theme::warning().render("Auto-corrected:"), + autocorrect::format_teaching_note(c) + ); + } + } +} + +/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode. +/// Also includes fuzzy command matching and flag-level suggestions. +fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionResult) -> ! { + use clap::error::ErrorKind; + + // Always let clap handle --help and --version normally (print and exit 0). + // These are intentional user actions, not errors, even when stdout is redirected. + if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) { + e.exit() + } + + if robot_mode { + let error_code = map_clap_error_kind(e.kind()); + let full_msg = e.to_string(); + let message = full_msg + .lines() + .take(3) + .collect::>() + .join("; ") + .trim() + .to_string(); + + let (suggestion, correction, valid_values) = match e.kind() { + // Phase 4: Suggest similar command for unknown subcommands + ErrorKind::InvalidSubcommand => { + let suggestion = if let Some(invalid_cmd) = extract_invalid_subcommand(&e) { + suggest_similar_command(&invalid_cmd) + } else { + "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 { + "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) + } + ErrorKind::MissingRequiredArgument => { + let suggestion = format!( + "A required argument is missing. {}", + if let Some(subcmd) = extract_subcommand_from_context(&e) { + format!( + "Example: {}. Run 'lore {subcmd} --help' for required arguments", + command_example(&subcmd) + ) + } else { + "Run 'lore robot-docs' for command reference".to_string() + } + ); + (suggestion, None, None) + } + ErrorKind::MissingSubcommand => { + let suggestion = + "No command specified. Common commands: issues, mrs, search, sync, \ + timeline, who, me. Run 'lore robot-docs' for the full list" + .to_string(); + (suggestion, None, None) + } + ErrorKind::TooFewValues | ErrorKind::TooManyValues => { + let suggestion = if let Some(subcmd) = extract_subcommand_from_context(&e) { + format!( + "Example: {}. Run 'lore {subcmd} --help' for usage", + command_example(&subcmd) + ) + } else { + "Run 'lore robot-docs' for command reference".to_string() + }; + (suggestion, None, None) + } + _ => ( + "Run 'lore robot-docs' for valid commands".to_string(), + None, + None, + ), + }; + + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: error_code.to_string(), + message, + suggestion, + correction, + valid_values, + }, + }; + eprintln!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|_| { + r#"{"error":{"code":"PARSE_ERROR","message":"Parse error"}}"#.to_string() + }) + ); + std::process::exit(2); + } else { + e.exit() + } +} + +/// Map clap ErrorKind to semantic error codes +fn map_clap_error_kind(kind: clap::error::ErrorKind) -> &'static str { + use clap::error::ErrorKind; + match kind { + ErrorKind::InvalidSubcommand => "UNKNOWN_COMMAND", + ErrorKind::UnknownArgument => "UNKNOWN_FLAG", + ErrorKind::MissingRequiredArgument => "MISSING_REQUIRED", + ErrorKind::InvalidValue => "INVALID_VALUE", + ErrorKind::ValueValidation => "INVALID_VALUE", + ErrorKind::TooManyValues => "TOO_MANY_VALUES", + ErrorKind::TooFewValues => "TOO_FEW_VALUES", + ErrorKind::ArgumentConflict => "ARGUMENT_CONFLICT", + ErrorKind::MissingSubcommand => "MISSING_COMMAND", + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => "HELP_REQUESTED", + _ => "PARSE_ERROR", + } +} + +/// Extract the invalid subcommand from a clap error (Phase 4) +fn extract_invalid_subcommand(e: &clap::Error) -> Option { + // Parse the error message to find the invalid subcommand + // Format is typically: "error: unrecognized subcommand 'foo'" + let msg = e.to_string(); + if let Some(start) = msg.find('\'') + && let Some(end) = msg[start + 1..].find('\'') + { + return Some(msg[start + 1..start + 1 + end].to_string()); + } + 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 { + 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, Option>) { + 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 '" + 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" + 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) +} + +/// Extract the subcommand context from a clap error for better suggestions. +/// Looks at the error message to find which command was being invoked. +fn extract_subcommand_from_context(e: &clap::Error) -> Option { + let msg = e.to_string(); + + let known = [ + "issues", + "mrs", + "notes", + "search", + "sync", + "ingest", + "count", + "status", + "auth", + "doctor", + "stats", + "timeline", + "who", + "me", + "drift", + "related", + "trace", + "file-history", + "generate-docs", + "embed", + "token", + "cron", + "init", + "migrate", + ]; + for cmd in known { + if msg.contains(&format!("lore {cmd}")) || msg.contains(&format!("'{cmd}'")) { + return Some(cmd.to_string()); + } + } + None +} + +/// Phase 4: Suggest similar command using fuzzy matching +fn suggest_similar_command(invalid: &str) -> String { + // Primary commands + common aliases for fuzzy matching + const VALID_COMMANDS: &[(&str, &str)] = &[ + ("issues", "issues"), + ("issue", "issues"), + ("mrs", "mrs"), + ("mr", "mrs"), + ("merge-requests", "mrs"), + ("search", "search"), + ("find", "search"), + ("query", "search"), + ("sync", "sync"), + ("ingest", "ingest"), + ("count", "count"), + ("status", "status"), + ("auth", "auth"), + ("doctor", "doctor"), + ("version", "version"), + ("init", "init"), + ("stats", "stats"), + ("stat", "stats"), + ("generate-docs", "generate-docs"), + ("embed", "embed"), + ("migrate", "migrate"), + ("health", "health"), + ("robot-docs", "robot-docs"), + ("completions", "completions"), + ("timeline", "timeline"), + ("who", "who"), + ("notes", "notes"), + ("note", "notes"), + ("drift", "drift"), + ("file-history", "file-history"), + ("trace", "trace"), + ("related", "related"), + ("me", "me"), + ("token", "token"), + ("cron", "cron"), + // Hidden but may be known to agents + ("list", "list"), + ("show", "show"), + ("reset", "reset"), + ("backup", "backup"), + ]; + + let invalid_lower = invalid.to_lowercase(); + + // Find the best match using Jaro-Winkler similarity + let best_match = VALID_COMMANDS + .iter() + .map(|(alias, canonical)| (*canonical, jaro_winkler(&invalid_lower, alias))) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((cmd, score)) = best_match + && score > 0.7 + { + let example = command_example(cmd); + return format!( + "Did you mean 'lore {cmd}'? Example: {example}. Run 'lore robot-docs' for all commands" + ); + } + + "Run 'lore robot-docs' for valid commands. Common: issues, mrs, search, sync, timeline, who" + .to_string() +} + +/// Return a contextual usage example for a command. +fn command_example(cmd: &str) -> &'static str { + match cmd { + "issues" => "lore --robot issues -n 10", + "mrs" => "lore --robot mrs -n 10", + "search" => "lore --robot search \"auth bug\"", + "sync" => "lore --robot sync", + "ingest" => "lore --robot ingest issues", + "notes" => "lore --robot notes --for-issue 123", + "count" => "lore --robot count issues", + "status" => "lore --robot status", + "stats" => "lore --robot stats", + "timeline" => "lore --robot timeline \"auth flow\"", + "who" => "lore --robot who --path src/", + "health" => "lore --robot health", + "generate-docs" => "lore --robot generate-docs", + "embed" => "lore --robot embed", + "robot-docs" => "lore robot-docs", + "trace" => "lore --robot trace src/main.rs", + "init" => "lore init", + "related" => "lore --robot related issues 42 -n 5", + "me" => "lore --robot me", + "drift" => "lore --robot drift issues 42", + "file-history" => "lore --robot file-history src/main.rs", + "token" => "lore --robot token show", + "cron" => "lore --robot cron status", + "auth" => "lore --robot auth", + "doctor" => "lore --robot doctor", + "migrate" => "lore --robot migrate", + "completions" => "lore completions bash", + _ => "lore --robot ", + } +} + diff --git a/src/app/robot_docs.rs b/src/app/robot_docs.rs new file mode 100644 index 0000000..38568d0 --- /dev/null +++ b/src/app/robot_docs.rs @@ -0,0 +1,821 @@ +#[derive(Serialize)] +struct RobotDocsOutput { + ok: bool, + data: RobotDocsData, +} + +#[derive(Serialize)] +struct RobotDocsData { + name: String, + version: String, + description: String, + activation: RobotDocsActivation, + quick_start: serde_json::Value, + commands: serde_json::Value, + /// Deprecated command aliases (old -> new) + aliases: serde_json::Value, + /// Pre-clap error tolerance: what the CLI auto-corrects + error_tolerance: serde_json::Value, + exit_codes: serde_json::Value, + /// Error codes emitted by clap parse failures + clap_error_codes: serde_json::Value, + error_format: String, + workflows: serde_json::Value, + config_notes: serde_json::Value, +} + +#[derive(Serialize)] +struct RobotDocsActivation { + flags: Vec, + env: String, + auto: String, +} + +fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box> { + let version = env!("CARGO_PKG_VERSION").to_string(); + + let commands = serde_json::json!({ + "init": { + "description": "Initialize configuration and database", + "flags": ["--force", "--non-interactive", "--gitlab-url ", "--token-env-var ", "--projects ", "--default-project "], + "robot_flags": ["--gitlab-url", "--token-env-var", "--projects", "--default-project"], + "example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project,other/repo --default-project group/project", + "response_schema": { + "ok": "bool", + "data": {"config_path": "string", "data_dir": "string", "user": {"username": "string", "name": "string"}, "projects": "[{path:string, name:string}]", "default_project": "string?"}, + "meta": {"elapsed_ms": "int"} + } + }, + "health": { + "description": "Quick pre-flight check: config, database, schema version", + "flags": [], + "example": "lore --robot health", + "response_schema": { + "ok": "bool", + "data": {"healthy": "bool", "config_found": "bool", "db_found": "bool", "schema_current": "bool", "schema_version": "int"}, + "meta": {"elapsed_ms": "int"} + } + }, + "auth": { + "description": "Verify GitLab authentication", + "flags": [], + "example": "lore --robot auth", + "response_schema": { + "ok": "bool", + "data": {"authenticated": "bool", "username": "string", "name": "string", "gitlab_url": "string"}, + "meta": {"elapsed_ms": "int"} + } + }, + "doctor": { + "description": "Full environment health check (config, auth, DB, Ollama)", + "flags": [], + "example": "lore --robot doctor", + "response_schema": { + "ok": "bool", + "data": {"success": "bool", "checks": "{config:object, auth:object, database:object, ollama:object}"}, + "meta": {"elapsed_ms": "int"} + } + }, + "ingest": { + "description": "Sync data from GitLab", + "flags": ["--project ", "--force", "--no-force", "--full", "--no-full", "--dry-run", "--no-dry-run", ""], + "example": "lore --robot ingest issues --project group/repo", + "response_schema": { + "ok": "bool", + "data": {"resource_type": "string", "projects_synced": "int", "issues_fetched?": "int", "mrs_fetched?": "int", "upserted": "int", "labels_created": "int", "discussions_fetched": "int", "notes_upserted": "int"}, + "meta": {"elapsed_ms": "int"} + } + }, + "sync": { + "description": "Full sync pipeline: ingest -> generate-docs -> embed. Supports surgical per-IID mode.", + "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--no-status", "--dry-run", "--no-dry-run", "-t/--timings", "--lock", "--issue ", "--mr ", "-p/--project ", "--preflight-only"], + "example": "lore --robot sync", + "surgical_mode": { + "description": "Sync specific issues or MRs by IID. Runs a scoped pipeline: preflight -> TOCTOU check -> ingest -> dependents -> docs -> embed.", + "flags": ["--issue (repeatable)", "--mr (repeatable)", "-p/--project (required)", "--preflight-only"], + "examples": [ + "lore --robot sync --issue 7 -p group/project", + "lore --robot sync --issue 7 --issue 42 --mr 10 -p group/project", + "lore --robot sync --issue 7 -p group/project --preflight-only" + ], + "constraints": ["--issue/--mr requires -p/--project (or defaultProject in config)", "--full and --issue/--mr are incompatible", "--preflight-only requires --issue or --mr", "Max 100 total targets"], + "entity_result_outcomes": ["synced", "skipped_stale", "not_found", "preflight_failed", "error"] + }, + "response_schema": { + "normal": { + "ok": "bool", + "data": {"issues_updated": "int", "mrs_updated": "int", "documents_regenerated": "int", "documents_embedded": "int", "resource_events_synced": "int", "resource_events_failed": "int"}, + "meta": {"elapsed_ms": "int", "stages?": "[{name:string, elapsed_ms:int, items_processed:int}]"} + }, + "surgical": { + "ok": "bool", + "data": {"surgical_mode": "true", "surgical_iids": "{issues:[int], merge_requests:[int]}", "entity_results": "[{entity_type:string, iid:int, outcome:string, error?:string, toctou_reason?:string}]", "preflight_only?": "bool", "issues_updated": "int", "mrs_updated": "int", "documents_regenerated": "int", "documents_embedded": "int", "discussions_fetched": "int"}, + "meta": {"elapsed_ms": "int"} + } + } + }, + "issues": { + "description": "List or show issues", + "flags": ["", "-n/--limit", "--fields ", "-s/--state", "--status ", "-p/--project", "-a/--author", "-A/--assignee", "-l/--label", "-m/--milestone", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"], + "example": "lore --robot issues --state opened --limit 10", + "notes": { + "status_filter": "--status filters by work item status NAME (case-insensitive). Valid values are in meta.available_statuses of any issues list response.", + "status_name": "status_name is the board column label (e.g. 'In review', 'Blocked'). This is the canonical status identifier for filtering." + }, + "response_schema": { + "list": { + "ok": "bool", + "data": {"issues": "[{iid:int, title:string, state:string, author_username:string, labels:[string], assignees:[string], discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, status_name:string?}]", "total_count": "int", "showing": "int"}, + "meta": {"elapsed_ms": "int", "available_statuses": "[string] — all distinct status names in the database, for use with --status filter"} + }, + "show": { + "ok": "bool", + "data": "IssueDetail (full entity with description, discussions, notes, events)", + "meta": {"elapsed_ms": "int"} + } + }, + "example_output": {"list": {"ok":true,"data":{"issues":[{"iid":3864,"title":"Switch Health Card","state":"opened","status_name":"In progress","labels":["customer:BNSF"],"assignees":["teernisse"],"discussion_count":12,"updated_at_iso":"2026-02-12T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":42}}}, + "fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]} + }, + "mrs": { + "description": "List or show merge requests", + "flags": ["", "-n/--limit", "--fields ", "-s/--state", "-p/--project", "-a/--author", "-A/--assignee", "-r/--reviewer", "-l/--label", "--since", "-d/--draft", "-D/--no-draft", "--target", "--source", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"], + "example": "lore --robot mrs --state opened", + "response_schema": { + "list": { + "ok": "bool", + "data": {"mrs": "[{iid:int, title:string, state:string, author_username:string, labels:[string], draft:bool, target_branch:string, source_branch:string, discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, reviewers:[string]}]", "total_count": "int", "showing": "int"}, + "meta": {"elapsed_ms": "int"} + }, + "show": { + "ok": "bool", + "data": "MrDetail (full entity with description, discussions, notes, events)", + "meta": {"elapsed_ms": "int"} + } + }, + "example_output": {"list": {"ok":true,"data":{"mrs":[{"iid":200,"title":"Add throw time chart","state":"opened","draft":false,"author_username":"teernisse","target_branch":"main","source_branch":"feat/throw-time","reviewers":["cseiber"],"discussion_count":5,"updated_at_iso":"2026-02-11T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":38}}}, + "fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]} + }, + "search": { + "description": "Search indexed documents (lexical, hybrid, semantic)", + "flags": ["", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--since", "--updated-since", "-n/--limit", "--fields ", "--explain", "--no-explain", "--fts-mode"], + "example": "lore --robot search 'authentication bug' --mode hybrid --limit 10", + "response_schema": { + "ok": "bool", + "data": {"results": "[{document_id:int, source_type:string, title:string, snippet:string, score:float, url:string?, author:string?, created_at:string?, updated_at:string?, project_path:string, labels:[string], paths:[string]}]", "total_results": "int", "query": "string", "mode": "string", "warnings": "[string]"}, + "meta": {"elapsed_ms": "int"} + }, + "example_output": {"ok":true,"data":{"query":"throw time","mode":"hybrid","total_results":3,"results":[{"document_id":42,"source_type":"issue","title":"Switch Health Card","score":0.92,"snippet":"...throw time data from BNSF...","project_path":"vs/typescript-code"}],"warnings":[]},"meta":{"elapsed_ms":85}}, + "fields_presets": {"minimal": ["document_id", "title", "source_type", "score"]} + }, + "count": { + "description": "Count entities in local database", + "flags": ["", "-f/--for "], + "example": "lore --robot count issues", + "response_schema": { + "ok": "bool", + "data": {"entity": "string", "count": "int", "system_excluded?": "int", "breakdown?": {"opened": "int", "closed": "int", "merged?": "int", "locked?": "int"}}, + "meta": {"elapsed_ms": "int"} + } + }, + "stats": { + "description": "Show document and index statistics", + "flags": ["--check", "--no-check", "--repair", "--dry-run", "--no-dry-run"], + "example": "lore --robot stats", + "response_schema": { + "ok": "bool", + "data": {"total_documents": "int", "indexed_documents": "int", "embedded_documents": "int", "stale_documents": "int", "integrity?": "object"}, + "meta": {"elapsed_ms": "int"} + } + }, + "status": { + "description": "Show sync state (cursors, last sync times)", + "flags": [], + "example": "lore --robot status", + "response_schema": { + "ok": "bool", + "data": {"projects": "[{path:string, issues_cursor:string?, mrs_cursor:string?, last_sync:string?}]"}, + "meta": {"elapsed_ms": "int"} + } + }, + "generate-docs": { + "description": "Generate searchable documents from ingested data", + "flags": ["--full", "-p/--project "], + "example": "lore --robot generate-docs --full", + "response_schema": { + "ok": "bool", + "data": {"generated": "int", "updated": "int", "unchanged": "int", "deleted": "int"}, + "meta": {"elapsed_ms": "int"} + } + }, + "embed": { + "description": "Generate vector embeddings for documents via Ollama", + "flags": ["--full", "--no-full", "--retry-failed", "--no-retry-failed"], + "example": "lore --robot embed", + "response_schema": { + "ok": "bool", + "data": {"embedded": "int", "skipped": "int", "failed": "int", "total_chunks": "int"}, + "meta": {"elapsed_ms": "int"} + } + }, + "migrate": { + "description": "Run pending database migrations", + "flags": [], + "example": "lore --robot migrate", + "response_schema": { + "ok": "bool", + "data": {"before_version": "int", "after_version": "int", "migrated": "bool"}, + "meta": {"elapsed_ms": "int"} + } + }, + "version": { + "description": "Show version information", + "flags": [], + "example": "lore --robot version", + "response_schema": { + "ok": "bool", + "data": {"version": "string", "git_hash?": "string"}, + "meta": {"elapsed_ms": "int"} + } + }, + "completions": { + "description": "Generate shell completions", + "flags": [""], + "example": "lore completions bash > ~/.local/share/bash-completion/completions/lore" + }, + "timeline": { + "description": "Chronological timeline of events matching a keyword query or entity reference", + "flags": ["", "-p/--project", "--since ", "--depth ", "--no-mentions", "-n/--limit", "--fields ", "--max-seeds", "--max-entities", "--max-evidence"], + "query_syntax": { + "search": "Any text -> hybrid search seeding (FTS5 + vector)", + "entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)" + }, + "example": "lore --robot timeline issue:42", + "response_schema": { + "ok": "bool", + "data": {"entities": "[{type:string, iid:int, title:string, project_path:string}]", "events": "[{timestamp:string, type:string, entity_type:string, entity_iid:int, detail:string}]", "total_events": "int"}, + "meta": {"elapsed_ms": "int", "search_mode": "string (hybrid|lexical|direct)"} + }, + "fields_presets": {"minimal": ["timestamp", "type", "entity_iid", "detail"]} + }, + "who": { + "description": "People intelligence: experts, workload, active discussions, overlap, review patterns", + "flags": ["", "--path ", "--active", "--overlap ", "--reviews", "--since ", "-p/--project", "-n/--limit", "--fields ", "--detail", "--no-detail", "--as-of ", "--explain-score", "--include-bots", "--include-closed", "--all-history"], + "modes": { + "expert": "lore who -- Who knows about this area? (also: --path for root files)", + "workload": "lore who -- What is someone working on?", + "reviews": "lore who --reviews -- Review pattern analysis", + "active": "lore who --active -- Active unresolved discussions", + "overlap": "lore who --overlap -- Who else is touching these files?" + }, + "example": "lore --robot who src/features/auth/", + "response_schema": { + "ok": "bool", + "data": { + "mode": "string", + "input": {"target": "string|null", "path": "string|null", "project": "string|null", "since": "string|null", "limit": "int"}, + "resolved_input": {"mode": "string", "project_id": "int|null", "project_path": "string|null", "since_ms": "int", "since_iso": "string", "since_mode": "string (default|explicit|none)", "limit": "int"}, + "...": "mode-specific fields" + }, + "meta": {"elapsed_ms": "int"} + }, + "example_output": {"expert": {"ok":true,"data":{"mode":"expert","result":{"experts":[{"username":"teernisse","score":42,"note_count":15,"diff_note_count":8}]}},"meta":{"elapsed_ms":65}}}, + "fields_presets": { + "expert_minimal": ["username", "score"], + "workload_minimal": ["entity_type", "iid", "title", "state"], + "active_minimal": ["entity_type", "iid", "title", "participants"] + } + }, + "trace": { + "description": "Trace why code was introduced: file -> MR -> issue -> discussion. Follows rename chains by default.", + "flags": ["", "-p/--project ", "--discussions", "--no-follow-renames", "-n/--limit "], + "example": "lore --robot trace src/main.rs -p group/repo", + "response_schema": { + "ok": "bool", + "data": {"path": "string", "resolved_paths": "[string]", "trace_chains": "[{mr_iid:int, mr_title:string, mr_state:string, mr_author:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, web_url:string?, issues:[{iid:int, title:string, state:string, reference_type:string, web_url:string?}], discussions:[{discussion_id:string, mr_iid:int, author_username:string, body_snippet:string, path:string, created_at_iso:string}]}]"}, + "meta": {"tier": "string (api_only)", "line_requested": "int?", "elapsed_ms": "int", "total_chains": "int", "renames_followed": "bool"} + } + }, + "file-history": { + "description": "Show MRs that touched a file, with rename chain resolution and optional DiffNote discussions", + "flags": ["", "-p/--project ", "--discussions", "--no-follow-renames", "--merged", "-n/--limit "], + "example": "lore --robot file-history src/main.rs -p group/repo", + "response_schema": { + "ok": "bool", + "data": {"path": "string", "rename_chain": "[string]?", "merge_requests": "[{iid:int, title:string, state:string, author_username:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, merge_commit_sha:string?, web_url:string?}]", "discussions": "[{discussion_id:string, author_username:string, body_snippet:string, path:string, created_at_iso:string}]?"}, + "meta": {"elapsed_ms": "int", "total_mrs": "int", "renames_followed": "bool", "paths_searched": "int"} + } + }, + "drift": { + "description": "Detect discussion divergence from original issue intent", + "flags": ["", "", "--threshold <0.0-1.0>", "-p/--project "], + "example": "lore --robot drift issues 42 --threshold 0.4", + "response_schema": { + "ok": "bool", + "data": {"entity_type": "string", "iid": "int", "title": "string", "threshold": "float", "divergent_discussions": "[{discussion_id:string, similarity:float, snippet:string}]"}, + "meta": {"elapsed_ms": "int"} + } + }, + "notes": { + "description": "List notes from discussions with rich filtering", + "flags": ["--limit/-n ", "--author/-a ", "--note-type ", "--contains ", "--for-issue ", "--for-mr ", "-p/--project ", "--since ", "--until ", "--path ", "--resolution ", "--sort ", "--asc", "--include-system", "--note-id ", "--gitlab-note-id ", "--discussion-id ", "--fields ", "--open"], + "robot_flags": ["--format json", "--fields minimal"], + "example": "lore --robot notes --author jdefting --since 1y --format json --fields minimal", + "response_schema": { + "ok": "bool", + "data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"}, + "meta": {"elapsed_ms": "int"} + } + }, + "cron": { + "description": "Manage cron-based automatic syncing (Unix only)", + "subcommands": { + "install": {"flags": ["--interval "], "default_interval": 8}, + "uninstall": {"flags": []}, + "status": {"flags": []} + }, + "example": "lore --robot cron status", + "response_schema": { + "ok": "bool", + "data": {"action": "string (install|uninstall|status)", "installed?": "bool", "interval_minutes?": "int", "entry?": "string", "log_path?": "string", "replaced?": "bool", "was_installed?": "bool", "last_run_iso?": "string"}, + "meta": {"elapsed_ms": "int"} + } + }, + "token": { + "description": "Manage stored GitLab token", + "subcommands": { + "set": {"flags": ["--token "], "note": "Reads from stdin if --token omitted in non-interactive mode"}, + "show": {"flags": ["--unmask"]} + }, + "example": "lore --robot token show", + "response_schema": { + "ok": "bool", + "data": {"action": "string (set|show)", "token_masked?": "string", "token?": "string", "valid?": "bool", "username?": "string"}, + "meta": {"elapsed_ms": "int"} + } + }, + "me": { + "description": "Personal work dashboard: open issues, authored/reviewing MRs, @mentioned-in items, activity feed, and cursor-based since-last-check inbox with computed attention states", + "flags": ["--issues", "--mrs", "--mentions", "--activity", "--since ", "-p/--project ", "--all", "--user ", "--fields ", "--reset-cursor"], + "example": "lore --robot me", + "response_schema": { + "ok": "bool", + "data": { + "username": "string", + "since_iso": "string?", + "summary": {"project_count": "int", "open_issue_count": "int", "authored_mr_count": "int", "reviewing_mr_count": "int", "mentioned_in_count": "int", "needs_attention_count": "int"}, + "since_last_check": "{cursor_iso:string, total_event_count:int, groups:[{entity_type:string, entity_iid:int, entity_title:string, project:string, events:[{timestamp_iso:string, event_type:string, actor:string?, summary:string, body_preview:string?}]}]}?", + "open_issues": "[{project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, status_name:string?, labels:[string], updated_at_iso:string, web_url:string?}]", + "open_mrs_authored": "[{project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, draft:bool, detailed_merge_status:string?, author_username:string?, labels:[string], updated_at_iso:string, web_url:string?}]", + "reviewing_mrs": "[same as open_mrs_authored]", + "mentioned_in": "[{entity_type:string, project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, updated_at_iso:string, web_url:string?}]", + "activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]" + }, + "meta": {"elapsed_ms": "int"} + }, + "fields_presets": { + "me_items_minimal": ["iid", "title", "attention_state", "attention_reason", "updated_at_iso"], + "me_mentions_minimal": ["entity_type", "iid", "title", "state", "attention_state", "attention_reason", "updated_at_iso"], + "me_activity_minimal": ["timestamp_iso", "event_type", "entity_iid", "actor"] + }, + "notes": { + "attention_states": "needs_attention | not_started | awaiting_response | stale | not_ready", + "event_types": "note | status_change | label_change | assign | unassign | review_request | milestone_change", + "section_flags": "If none of --issues/--mrs/--mentions/--activity specified, all sections returned", + "since_default": "1d for activity feed", + "issue_filter": "Only In Progress / In Review status issues shown", + "since_last_check": "Cursor-based inbox showing events since last run. Null on first run (no cursor yet). Groups events by entity (issue/MR). Sources: others' comments on your items, @mentions, assignment/review-request notes. Cursor auto-advances after each run. Use --reset-cursor to clear.", + "cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_.json. --project filters display only for since-last-check; cursor still advances for all projects for that user." + } + }, + "robot-docs": { + "description": "This command (agent self-discovery manifest)", + "flags": ["--brief"], + "example": "lore robot-docs --brief" + } + }); + + let quick_start = serde_json::json!({ + "glab_equivalents": [ + { "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs, discussion counts" }, + { "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes full discussions, work-item status, cross-references" }, + { "glab": "glab issue list -l bug", "lore": "lore -J issues --label bug", "note": "AND logic for multiple --label flags" }, + { "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers, discussion counts" }, + { "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, review threads, source/target branches" }, + { "glab": "glab mr list -s opened", "lore": "lore -J mrs -s opened", "note": "States: opened, merged, closed, locked, all" }, + { "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching (suffix or substring)" } + ], + "lore_exclusive": [ + "search: FTS5 + vector hybrid search across all entities", + "who: Expert/workload/reviews analysis per file path or person", + "timeline: Chronological event reconstruction across entities", + "trace: Code provenance chains (file -> MR -> issue -> discussion)", + "file-history: MR history per file with rename resolution", + "notes: Rich note listing with author, type, resolution, path, and discussion filters", + "stats: Database statistics with document/note/discussion counts", + "count: Entity counts with state breakdowns", + "embed: Generate vector embeddings for semantic search via Ollama", + "cron: Automated sync scheduling (Unix)", + "token: Secure token management with masked display", + "me: Personal work dashboard with attention states, activity feed, cursor-based since-last-check inbox, and needs-attention triage" + ], + "read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)." + }); + + // --brief: strip response_schema and example_output from every command (~60% smaller) + let mut commands = commands; + if brief { + strip_schemas(&mut commands); + } + + let exit_codes = serde_json::json!({ + "0": "Success", + "1": "Internal error", + "2": "Usage error (invalid flags or arguments)", + "3": "Config invalid", + "4": "Token not set", + "5": "GitLab auth failed", + "6": "Resource not found", + "7": "Rate limited", + "8": "Network error", + "9": "Database locked", + "10": "Database error", + "11": "Migration failed", + "12": "I/O error", + "13": "Transform error", + "14": "Ollama unavailable", + "15": "Ollama model not found", + "16": "Embedding failed", + "17": "Not found", + "18": "Ambiguous match", + "19": "Health check failed", + "20": "Config not found" + }); + + let workflows = serde_json::json!({ + "first_setup": [ + "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project", + "lore --robot doctor", + "lore --robot sync" + ], + "daily_sync": [ + "lore --robot sync" + ], + "search": [ + "lore --robot search 'query' --mode hybrid" + ], + "pre_flight": [ + "lore --robot health" + ], + "temporal_intelligence": [ + "lore --robot sync", + "lore --robot timeline '' --since 30d", + "lore --robot timeline '' --depth 2" + ], + "people_intelligence": [ + "lore --robot who src/path/to/feature/", + "lore --robot who @username", + "lore --robot who @username --reviews", + "lore --robot who --active --since 7d", + "lore --robot who --overlap src/path/", + "lore --robot who --path README.md" + ], + "surgical_sync": [ + "lore --robot sync --issue 7 -p group/project", + "lore --robot sync --issue 7 --mr 10 -p group/project", + "lore --robot sync --issue 7 -p group/project --preflight-only" + ], + "personal_dashboard": [ + "lore --robot me", + "lore --robot me --issues", + "lore --robot me --activity --since 7d", + "lore --robot me --project group/repo", + "lore --robot me --fields minimal", + "lore --robot me --reset-cursor" + ] + }); + + // Phase 3: Deprecated command aliases + let aliases = serde_json::json!({ + "deprecated_commands": { + "list issues": "issues", + "list mrs": "mrs", + "show issue ": "issues ", + "show mr ": "mrs ", + "auth-test": "auth", + "sync-status": "status" + }, + "command_aliases": { + "issue": "issues", + "mr": "mrs", + "merge-requests": "mrs", + "merge-request": "mrs", + "mergerequests": "mrs", + "mergerequest": "mrs", + "generate-docs": "generate-docs", + "generatedocs": "generate-docs", + "gendocs": "generate-docs", + "gen-docs": "generate-docs", + "robot-docs": "robot-docs", + "robotdocs": "robot-docs" + }, + "pre_clap_aliases": { + "note": "Underscore/no-separator forms auto-corrected before parsing", + "merge_requests": "mrs", + "merge_request": "mrs", + "mergerequests": "mrs", + "mergerequest": "mrs", + "generate_docs": "generate-docs", + "generatedocs": "generate-docs", + "gendocs": "generate-docs", + "gen-docs": "generate-docs", + "robot-docs": "robot-docs", + "robotdocs": "robot-docs" + }, + "prefix_matching": "Enabled via infer_subcommands. Unambiguous prefixes work: 'iss' -> issues, 'time' -> timeline, 'sea' -> search" + }); + + let error_tolerance = serde_json::json!({ + "note": "The CLI auto-corrects common mistakes before parsing. Corrections are applied silently with a teaching note on stderr.", + "auto_corrections": [ + {"type": "single_dash_long_flag", "example": "-robot -> --robot", "mode": "all"}, + {"type": "case_normalization", "example": "--Robot -> --robot, --State -> --state", "mode": "all"}, + {"type": "flag_prefix", "example": "--proj -> --project (when unambiguous)", "mode": "all"}, + {"type": "fuzzy_flag", "example": "--projct -> --project", "mode": "all (threshold 0.9 in robot, 0.8 in human)"}, + {"type": "subcommand_alias", "example": "merge_requests -> mrs, robotdocs -> robot-docs", "mode": "all"}, + {"type": "subcommand_fuzzy", "example": "issuess -> issues, timline -> timeline, serach -> search", "mode": "all (threshold 0.85)"}, + {"type": "flag_as_subcommand", "example": "--robot-docs -> robot-docs, --generate-docs -> generate-docs", "mode": "all"}, + {"type": "value_normalization", "example": "--state Opened -> --state opened", "mode": "all"}, + {"type": "value_fuzzy", "example": "--state opend -> --state opened", "mode": "all"}, + {"type": "prefix_matching", "example": "lore iss -> lore issues, lore time -> lore timeline", "mode": "all (via clap infer_subcommands)"} + ], + "teaching_notes": "Auto-corrections emit a JSON warning on stderr: {\"warning\":{\"type\":\"ARG_CORRECTED\",\"corrections\":[...],\"teaching\":[...]}}" + }); + + // Phase 3: Clap error codes (emitted by handle_clap_error) + let clap_error_codes = serde_json::json!({ + "UNKNOWN_COMMAND": "Unrecognized subcommand (includes fuzzy suggestion)", + "UNKNOWN_FLAG": "Unrecognized command-line flag", + "MISSING_REQUIRED": "Required argument not provided", + "INVALID_VALUE": "Invalid value for argument", + "TOO_MANY_VALUES": "Too many values provided", + "TOO_FEW_VALUES": "Too few values provided", + "ARGUMENT_CONFLICT": "Conflicting arguments", + "MISSING_COMMAND": "No subcommand provided (in non-robot mode, shows help)", + "HELP_REQUESTED": "Help or version flag used", + "PARSE_ERROR": "General parse error" + }); + + let config_notes = serde_json::json!({ + "defaultProject": { + "type": "string?", + "description": "Fallback project path used when -p/--project is omitted. Must match a configured project path (exact or suffix). CLI -p always overrides.", + "example": "group/project" + } + }); + + let output = RobotDocsOutput { + ok: true, + data: RobotDocsData { + name: "lore".to_string(), + version, + description: "Local GitLab data management with semantic search".to_string(), + activation: RobotDocsActivation { + flags: vec!["--robot".to_string(), "-J".to_string(), "--json".to_string()], + env: "LORE_ROBOT=1".to_string(), + auto: "Non-TTY stdout".to_string(), + }, + quick_start, + commands, + aliases, + error_tolerance, + exit_codes, + clap_error_codes, + error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\",\"actions\":[\"...\"]}}".to_string(), + workflows, + config_notes, + }, + }; + + if robot_mode { + println!("{}", serde_json::to_string(&output)?); + } else { + println!("{}", serde_json::to_string_pretty(&output)?); + } + + Ok(()) +} + +fn handle_who( + config_override: Option<&str>, + mut args: WhoArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + if args.project.is_none() { + args.project = config.default_project.clone(); + } + let run = run_who(&config, &args)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + + if robot_mode { + print_who_json(&run, &args, elapsed_ms); + } else { + print_who_human(&run.result, run.resolved_input.project_path.as_deref()); + } + Ok(()) +} + +fn handle_me( + config_override: Option<&str>, + args: MeArgs, + robot_mode: bool, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + run_me(&config, &args, robot_mode)?; + Ok(()) +} + +async fn handle_drift( + config_override: Option<&str>, + entity_type: &str, + iid: i64, + threshold: f32, + project: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let effective_project = config.effective_project(project); + let response = run_drift(&config, entity_type, iid, threshold, effective_project).await?; + let elapsed_ms = start.elapsed().as_millis() as u64; + + if robot_mode { + print_drift_json(&response, elapsed_ms); + } else { + print_drift_human(&response); + } + Ok(()) +} + +async fn handle_related( + config_override: Option<&str>, + query_or_type: &str, + iid: Option, + limit: usize, + project: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let effective_project = config.effective_project(project); + let response = run_related(&config, query_or_type, iid, limit, effective_project).await?; + let elapsed_ms = start.elapsed().as_millis() as u64; + + if robot_mode { + print_related_json(&response, elapsed_ms); + } else { + print_related_human(&response); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_list_compat( + config_override: Option<&str>, + entity: &str, + limit: usize, + project_filter: Option<&str>, + state_filter: Option<&str>, + author_filter: Option<&str>, + assignee_filter: Option<&str>, + label_filter: Option<&[String]>, + milestone_filter: Option<&str>, + since_filter: Option<&str>, + due_before_filter: Option<&str>, + has_due_date: bool, + sort: &str, + order: &str, + open_browser: bool, + json_output: bool, + draft: bool, + no_draft: bool, + reviewer_filter: Option<&str>, + target_branch_filter: Option<&str>, + source_branch_filter: Option<&str>, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let project_filter = config.effective_project(project_filter); + + let state_normalized = state_filter.map(str::to_lowercase); + match entity { + "issues" => { + let filters = ListFilters { + limit, + project: project_filter, + state: state_normalized.as_deref(), + author: author_filter, + assignee: assignee_filter, + labels: label_filter, + milestone: milestone_filter, + since: since_filter, + due_before: due_before_filter, + has_due_date, + statuses: &[], + sort, + order, + }; + + let result = run_list_issues(&config, filters)?; + + if open_browser { + open_issue_in_browser(&result); + } else if json_output { + print_list_issues_json(&result, start.elapsed().as_millis() as u64, None); + } else { + print_list_issues(&result); + } + + Ok(()) + } + "mrs" => { + let filters = MrListFilters { + limit, + project: project_filter, + state: state_normalized.as_deref(), + author: author_filter, + assignee: assignee_filter, + reviewer: reviewer_filter, + labels: label_filter, + since: since_filter, + draft, + no_draft, + target_branch: target_branch_filter, + source_branch: source_branch_filter, + sort, + order, + }; + + let result = run_list_mrs(&config, filters)?; + + if open_browser { + open_mr_in_browser(&result); + } else if json_output { + print_list_mrs_json(&result, start.elapsed().as_millis() as u64, None); + } else { + print_list_mrs(&result); + } + + Ok(()) + } + _ => { + eprintln!( + "{}", + Theme::error().render(&format!("Unknown entity: {entity}")) + ); + std::process::exit(1); + } + } +} + +async fn handle_show_compat( + config_override: Option<&str>, + entity: &str, + iid: i64, + project_filter: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let project_filter = config.effective_project(project_filter); + + match entity { + "issue" => { + let result = run_show_issue(&config, iid, project_filter)?; + if robot_mode { + print_show_issue_json(&result, start.elapsed().as_millis() as u64); + } else { + print_show_issue(&result); + } + Ok(()) + } + "mr" => { + let result = run_show_mr(&config, iid, project_filter)?; + if robot_mode { + print_show_mr_json(&result, start.elapsed().as_millis() as u64); + } else { + print_show_mr(&result); + } + Ok(()) + } + _ => { + eprintln!( + "{}", + Theme::error().render(&format!("Unknown entity: {entity}")) + ); + std::process::exit(1); + } + } +} diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..5b5c231 --- /dev/null +++ b/src/cli/args.rs @@ -0,0 +1,870 @@ +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore issues -n 10 # List 10 most recently updated issues + lore issues -s opened -l bug # Open issues labeled 'bug' + lore issues 42 -p group/repo # Show issue #42 in a specific project + lore issues --since 7d -a jsmith # Issues updated in last 7 days by jsmith")] +pub struct IssuesArgs { + /// Issue IID (omit to list, provide to show details) + pub iid: Option, + + /// Maximum results + #[arg( + short = 'n', + long = "limit", + default_value = "50", + help_heading = "Output" + )] + pub limit: usize, + + /// Select output fields (comma-separated, or 'minimal' preset: iid,title,state,updated_at_iso) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Filter by state (opened, closed, all) + #[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "closed", "all"])] + pub state: Option, + + /// Filter by project path + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Filter by author username + #[arg(short = 'a', long, help_heading = "Filters")] + pub author: Option, + + /// Filter by assignee username + #[arg(short = 'A', long, help_heading = "Filters")] + pub assignee: Option, + + /// Filter by label (repeatable, AND logic) + #[arg(short = 'l', long, help_heading = "Filters")] + pub label: Option>, + + /// Filter by milestone title + #[arg(short = 'm', long, help_heading = "Filters")] + pub milestone: Option, + + /// Filter by work-item status name (repeatable, OR logic) + #[arg(long, help_heading = "Filters")] + pub status: Vec, + + /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Filter by due date (before this date, YYYY-MM-DD) + #[arg(long = "due-before", help_heading = "Filters")] + pub due_before: Option, + + /// Show only issues with a due date + #[arg( + long = "has-due", + help_heading = "Filters", + overrides_with = "no_has_due" + )] + pub has_due: bool, + + #[arg(long = "no-has-due", hide = true, overrides_with = "has_due")] + pub no_has_due: bool, + + /// Sort field (updated, created, iid) + #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] + pub sort: String, + + /// Sort ascending (default: descending) + #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] + pub asc: bool, + + #[arg(long = "no-asc", hide = true, overrides_with = "asc")] + pub no_asc: bool, + + /// Open first matching item in browser + #[arg( + short = 'o', + long, + help_heading = "Actions", + overrides_with = "no_open" + )] + pub open: bool, + + #[arg(long = "no-open", hide = true, overrides_with = "open")] + pub no_open: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore mrs -s opened # List open merge requests + lore mrs -s merged --since 2w # MRs merged in the last 2 weeks + lore mrs 99 -p group/repo # Show MR !99 in a specific project + lore mrs -D --reviewer jsmith # Non-draft MRs reviewed by jsmith")] +pub struct MrsArgs { + /// MR IID (omit to list, provide to show details) + pub iid: Option, + + /// Maximum results + #[arg( + short = 'n', + long = "limit", + default_value = "50", + help_heading = "Output" + )] + pub limit: usize, + + /// Select output fields (comma-separated, or 'minimal' preset: iid,title,state,updated_at_iso) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Filter by state (opened, merged, closed, locked, all) + #[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "merged", "closed", "locked", "all"])] + pub state: Option, + + /// Filter by project path + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Filter by author username + #[arg(short = 'a', long, help_heading = "Filters")] + pub author: Option, + + /// Filter by assignee username + #[arg(short = 'A', long, help_heading = "Filters")] + pub assignee: Option, + + /// Filter by reviewer username + #[arg(short = 'r', long, help_heading = "Filters")] + pub reviewer: Option, + + /// Filter by label (repeatable, AND logic) + #[arg(short = 'l', long, help_heading = "Filters")] + pub label: Option>, + + /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Show only draft MRs + #[arg( + short = 'd', + long, + conflicts_with = "no_draft", + help_heading = "Filters" + )] + pub draft: bool, + + /// Exclude draft MRs + #[arg( + short = 'D', + long = "no-draft", + conflicts_with = "draft", + help_heading = "Filters" + )] + pub no_draft: bool, + + /// Filter by target branch + #[arg(long, help_heading = "Filters")] + pub target: Option, + + /// Filter by source branch + #[arg(long, help_heading = "Filters")] + pub source: Option, + + /// Sort field (updated, created, iid) + #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] + pub sort: String, + + /// Sort ascending (default: descending) + #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] + pub asc: bool, + + #[arg(long = "no-asc", hide = true, overrides_with = "asc")] + pub no_asc: bool, + + /// Open first matching item in browser + #[arg( + short = 'o', + long, + help_heading = "Actions", + overrides_with = "no_open" + )] + pub open: bool, + + #[arg(long = "no-open", hide = true, overrides_with = "open")] + pub no_open: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore notes # List 50 most recent notes + lore notes --author alice --since 7d # Notes by alice in last 7 days + lore notes --for-issue 42 -p group/repo # Notes on issue #42 + lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/")] +pub struct NotesArgs { + /// Maximum results + #[arg( + short = 'n', + long = "limit", + default_value = "50", + help_heading = "Output" + )] + pub limit: usize, + + /// Select output fields (comma-separated, or 'minimal' preset: id,author_username,body,created_at_iso) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Filter by author username + #[arg(short = 'a', long, help_heading = "Filters")] + pub author: Option, + + /// Filter by note type (DiffNote, DiscussionNote) + #[arg(long, help_heading = "Filters")] + pub note_type: Option, + + /// Filter by body text (substring match) + #[arg(long, help_heading = "Filters")] + pub contains: Option, + + /// Filter by internal note ID + #[arg(long, help_heading = "Filters")] + pub note_id: Option, + + /// Filter by GitLab note ID + #[arg(long, help_heading = "Filters")] + pub gitlab_note_id: Option, + + /// Filter by discussion ID + #[arg(long, help_heading = "Filters")] + pub discussion_id: Option, + + /// Include system notes (excluded by default) + #[arg(long, help_heading = "Filters")] + pub include_system: bool, + + /// Filter to notes on a specific issue IID (requires --project or default_project) + #[arg(long, conflicts_with = "for_mr", help_heading = "Filters")] + pub for_issue: Option, + + /// Filter to notes on a specific MR IID (requires --project or default_project) + #[arg(long, conflicts_with = "for_issue", help_heading = "Filters")] + pub for_mr: Option, + + /// Filter by project path + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Filter until date (YYYY-MM-DD, inclusive end-of-day) + #[arg(long, help_heading = "Filters")] + pub until: Option, + + /// Filter by file path (exact match or prefix with trailing /) + #[arg(long, help_heading = "Filters")] + pub path: Option, + + /// Filter by resolution status (any, unresolved, resolved) + #[arg( + long, + value_parser = ["any", "unresolved", "resolved"], + help_heading = "Filters" + )] + pub resolution: Option, + + /// Sort field (created, updated) + #[arg( + long, + value_parser = ["created", "updated"], + default_value = "created", + help_heading = "Sorting" + )] + pub sort: String, + + /// Sort ascending (default: descending) + #[arg(long, help_heading = "Sorting")] + pub asc: bool, + + /// Open first matching item in browser + #[arg(long, help_heading = "Actions")] + pub open: bool, +} + +#[derive(Parser)] +pub struct IngestArgs { + /// Entity to ingest (issues, mrs). Omit to ingest everything + #[arg(value_parser = ["issues", "mrs"])] + pub entity: Option, + + /// Filter to single project + #[arg(short = 'p', long)] + pub project: Option, + + /// Override stale sync lock + #[arg(short = 'f', long, overrides_with = "no_force")] + pub force: bool, + + #[arg(long = "no-force", hide = true, overrides_with = "force")] + pub no_force: bool, + + /// Full re-sync: reset cursors and fetch all data from scratch + #[arg(long, overrides_with = "no_full")] + pub full: bool, + + #[arg(long = "no-full", hide = true, overrides_with = "full")] + pub no_full: bool, + + /// Preview what would be synced without making changes + #[arg(long, overrides_with = "no_dry_run")] + pub dry_run: bool, + + #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] + pub no_dry_run: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore stats # Show document and index statistics + lore stats --check # Run integrity checks + lore stats --repair --dry-run # Preview what repair would fix + lore --robot stats # JSON output for automation")] +pub struct StatsArgs { + /// Run integrity checks + #[arg(long, overrides_with = "no_check")] + pub check: bool, + + #[arg(long = "no-check", hide = true, overrides_with = "check")] + pub no_check: bool, + + /// Repair integrity issues (auto-enables --check) + #[arg(long)] + pub repair: bool, + + /// Preview what would be repaired without making changes (requires --repair) + #[arg(long, overrides_with = "no_dry_run")] + pub dry_run: bool, + + #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] + pub no_dry_run: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore search 'authentication bug' # Hybrid search (default) + lore search 'deploy' --mode lexical --type mr # Lexical search, MRs only + lore search 'API rate limit' --since 30d # Recent results only + lore search 'config' -p group/repo --explain # With ranking explanation")] +pub struct SearchArgs { + /// Search query string + pub query: String, + + /// Search mode (lexical, hybrid, semantic) + #[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Mode")] + pub mode: String, + + /// Filter by source type (issue, mr, discussion, note) + #[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion", "note"], help_heading = "Filters")] + pub source_type: Option, + + /// Filter by author username + #[arg(long, help_heading = "Filters")] + pub author: Option, + + /// Filter by project path + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Filter by label (repeatable, AND logic) + #[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")] + pub label: Vec, + + /// Filter by file path (trailing / for prefix match) + #[arg(long, help_heading = "Filters")] + pub path: Option, + + /// Filter by created since (7d, 2w, or YYYY-MM-DD) + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Filter by updated since (7d, 2w, or YYYY-MM-DD) + #[arg(long = "updated-since", help_heading = "Filters")] + pub updated_since: Option, + + /// Maximum results (default 20, max 100) + #[arg( + short = 'n', + long = "limit", + default_value = "20", + help_heading = "Output" + )] + pub limit: usize, + + /// Select output fields (comma-separated, or 'minimal' preset: document_id,title,source_type,score) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Show ranking explanation per result + #[arg(long, help_heading = "Output", overrides_with = "no_explain")] + pub explain: bool, + + #[arg(long = "no-explain", hide = true, overrides_with = "explain")] + pub no_explain: bool, + + /// FTS query mode: safe (default) or raw + #[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Mode")] + pub fts_mode: String, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore generate-docs # Generate docs for dirty entities + lore generate-docs --full # Full rebuild of all documents + lore generate-docs --full -p group/repo # Full rebuild for one project")] +pub struct GenerateDocsArgs { + /// Full rebuild: seed all entities into dirty queue, then drain + #[arg(long)] + pub full: bool, + + /// Filter to single project + #[arg(short = 'p', long)] + pub project: Option, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore sync # Full pipeline: ingest + docs + embed + lore sync --no-embed # Skip embedding step + lore sync --no-status # Skip work-item status enrichment + lore sync --full --force # Full re-sync, override stale lock + lore sync --dry-run # Preview what would change + lore sync --issue 42 -p group/repo # Surgically sync one issue + lore sync --mr 10 --mr 20 -p g/r # Surgically sync two MRs")] +pub struct SyncArgs { + /// Reset cursors, fetch everything + #[arg(long, overrides_with = "no_full")] + pub full: bool, + + #[arg(long = "no-full", hide = true, overrides_with = "full")] + pub no_full: bool, + + /// Override stale lock + #[arg(long, overrides_with = "no_force")] + pub force: bool, + + #[arg(long = "no-force", hide = true, overrides_with = "force")] + pub no_force: bool, + + /// Skip embedding step + #[arg(long)] + pub no_embed: bool, + + /// Skip document regeneration + #[arg(long)] + pub no_docs: bool, + + /// Skip resource event fetching (overrides config) + #[arg(long = "no-events")] + pub no_events: bool, + + /// Skip MR file change fetching (overrides config) + #[arg(long = "no-file-changes")] + pub no_file_changes: bool, + + /// Skip work-item status enrichment via GraphQL (overrides config) + #[arg(long = "no-status")] + pub no_status: bool, + + /// Preview what would be synced without making changes + #[arg(long, overrides_with = "no_dry_run")] + pub dry_run: bool, + + #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] + pub no_dry_run: bool, + + /// Show detailed timing breakdown for sync stages + #[arg(short = 't', long = "timings")] + pub timings: bool, + + /// Acquire file lock before syncing (skip if another sync is running) + #[arg(long)] + pub lock: bool, + + /// Surgically sync specific issues by IID (repeatable, must be positive) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)] + pub issue: Vec, + + /// Surgically sync specific merge requests by IID (repeatable, must be positive) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)] + pub mr: Vec, + + /// Scope to a single project (required when --issue or --mr is used) + #[arg(short = 'p', long)] + pub project: Option, + + /// Validate remote entities exist without DB writes (preflight only) + #[arg(long)] + pub preflight_only: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore embed # Embed new/changed documents + lore embed --full # Re-embed all documents from scratch + lore embed --retry-failed # Retry previously failed embeddings")] +pub struct EmbedArgs { + /// Re-embed all documents (clears existing embeddings first) + #[arg(long, overrides_with = "no_full")] + pub full: bool, + + #[arg(long = "no-full", hide = true, overrides_with = "full")] + pub no_full: bool, + + /// Retry previously failed embeddings + #[arg(long, overrides_with = "no_retry_failed")] + pub retry_failed: bool, + + #[arg(long = "no-retry-failed", hide = true, overrides_with = "retry_failed")] + pub no_retry_failed: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore timeline 'deployment' # Search-based seeding + lore timeline issue:42 # Direct: issue #42 and related entities + lore timeline i:42 # Shorthand for issue:42 + lore timeline mr:99 # Direct: MR !99 and related entities + lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time + lore timeline 'migration' --depth 2 # Deep cross-reference expansion + lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")] +pub struct TimelineArgs { + /// Search text or entity reference (issue:N, i:N, mr:N, m:N) + pub query: String, + + /// Scope to a specific project (fuzzy match) + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Only show events after this date (e.g. "6m", "2w", "2024-01-01") + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Cross-reference expansion depth (0 = no expansion) + #[arg(long, default_value = "1", help_heading = "Expansion")] + pub depth: u32, + + /// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related') + #[arg(long = "no-mentions", help_heading = "Expansion")] + pub no_mentions: bool, + + /// Maximum number of events to display + #[arg( + short = 'n', + long = "limit", + default_value = "100", + help_heading = "Output" + )] + pub limit: usize, + + /// Select output fields (comma-separated, or 'minimal' preset: timestamp,type,entity_iid,detail) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Maximum seed entities from search + #[arg(long = "max-seeds", default_value = "10", help_heading = "Expansion")] + pub max_seeds: usize, + + /// Maximum expanded entities via cross-references + #[arg( + long = "max-entities", + default_value = "50", + help_heading = "Expansion" + )] + pub max_entities: usize, + + /// Maximum evidence notes included + #[arg( + long = "max-evidence", + default_value = "10", + help_heading = "Expansion" + )] + pub max_evidence: usize, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore who src/features/auth/ # Who knows about this area? + lore who @asmith # What is asmith working on? + lore who @asmith --reviews # What review patterns does asmith have? + lore who --active # What discussions need attention? + lore who --overlap src/features/auth/ # Who else is touching these files? + lore who --path README.md # Expert lookup for a root file + lore who --path Makefile # Expert lookup for a dotless root file")] +pub struct WhoArgs { + /// Username or file path (path if contains /) + pub target: Option, + + /// Force expert mode for a file/directory path. + /// Root files (README.md, LICENSE, Makefile) are treated as exact matches. + /// Use a trailing `/` to force directory-prefix matching. + #[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])] + pub path: Option, + + /// Show active unresolved discussions + #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])] + pub active: bool, + + /// Find users with MRs/notes touching this file path + #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])] + pub overlap: Option, + + /// Show review pattern analysis (requires username target) + #[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])] + pub reviews: bool, + + /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Scope to a project (supports fuzzy matching) + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Maximum results per section (1..=500); omit for unlimited + #[arg( + short = 'n', + long = "limit", + value_parser = clap::value_parser!(u16).range(1..=500), + help_heading = "Output" + )] + pub limit: Option, + + /// Select output fields (comma-separated, or 'minimal' preset; varies by mode) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Show per-MR detail breakdown (expert mode only) + #[arg( + long, + help_heading = "Output", + overrides_with = "no_detail", + conflicts_with = "explain_score" + )] + pub detail: bool, + + #[arg(long = "no-detail", hide = true, overrides_with = "detail")] + pub no_detail: bool, + + /// Score as if "now" is this date (ISO 8601 or duration like 30d). Expert mode only. + #[arg(long = "as-of", help_heading = "Scoring")] + pub as_of: Option, + + /// Show per-component score breakdown in output. Expert mode only. + #[arg(long = "explain-score", help_heading = "Scoring")] + pub explain_score: bool, + + /// Include bot users in results (normally excluded via scoring.excluded_usernames). + #[arg(long = "include-bots", help_heading = "Scoring")] + pub include_bots: bool, + + /// Include discussions on closed issues and merged/closed MRs + #[arg(long, help_heading = "Filters")] + pub include_closed: bool, + + /// Remove the default time window (query all history). Conflicts with --since. + #[arg( + long = "all-history", + help_heading = "Filters", + conflicts_with = "since" + )] + pub all_history: bool, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore me # Full dashboard (default project or all) + lore me --issues # Issues section only + lore me --mrs # MRs section only + lore me --activity # Activity feed only + lore me --all # All synced projects + lore me --since 2d # Activity window (default: 30d) + lore me --project group/repo # Scope to one project + lore me --user jdoe # Override configured username")] +pub struct MeArgs { + /// Show open issues section + #[arg(long, help_heading = "Sections")] + pub issues: bool, + + /// Show authored + reviewing MRs section + #[arg(long, help_heading = "Sections")] + pub mrs: bool, + + /// Show activity feed section + #[arg(long, help_heading = "Sections")] + pub activity: bool, + + /// Show items you're @mentioned in (not assigned/authored/reviewing) + #[arg(long, help_heading = "Sections")] + pub mentions: bool, + + /// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section. + #[arg(long, help_heading = "Filters")] + pub since: Option, + + /// Scope to a project (supports fuzzy matching) + #[arg(short = 'p', long, help_heading = "Filters", conflicts_with = "all")] + pub project: Option, + + /// Show all synced projects (overrides default_project) + #[arg(long, help_heading = "Filters", conflicts_with = "project")] + pub all: bool, + + /// Override configured username + #[arg(long = "user", help_heading = "Filters")] + pub user: Option, + + /// Select output fields (comma-separated, or 'minimal' preset) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Reset the since-last-check cursor (next run shows no new events) + #[arg(long, help_heading = "Output")] + pub reset_cursor: bool, +} + +impl MeArgs { + /// Returns true if no section flags were passed (show all sections). + pub fn show_all_sections(&self) -> bool { + !self.issues && !self.mrs && !self.activity && !self.mentions + } +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore file-history src/main.rs # MRs that touched this file + lore file-history src/auth/ -p group/repo # Scoped to project + lore file-history src/foo.rs --discussions # Include DiffNote snippets + lore file-history src/bar.rs --no-follow-renames # Skip rename chain")] +pub struct FileHistoryArgs { + /// File path to trace history for + pub path: String, + + /// Scope to a specific project (fuzzy match) + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Include discussion snippets from DiffNotes on this file + #[arg(long, help_heading = "Output")] + pub discussions: bool, + + /// Disable rename chain resolution + #[arg(long = "no-follow-renames", help_heading = "Filters")] + pub no_follow_renames: bool, + + /// Only show merged MRs + #[arg(long, help_heading = "Filters")] + pub merged: bool, + + /// Maximum results + #[arg( + short = 'n', + long = "limit", + default_value = "50", + help_heading = "Output" + )] + pub limit: usize, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore trace src/main.rs # Why was this file changed? + lore trace src/auth/ -p group/repo # Scoped to project + lore trace src/foo.rs --discussions # Include DiffNote context + lore trace src/bar.rs:42 # Line hint (Tier 2 warning)")] +pub struct TraceArgs { + /// File path to trace (supports :line suffix for future Tier 2) + pub path: String, + + /// Scope to a specific project (fuzzy match) + #[arg(short = 'p', long, help_heading = "Filters")] + pub project: Option, + + /// Include DiffNote discussion snippets + #[arg(long, help_heading = "Output")] + pub discussions: bool, + + /// Disable rename chain resolution + #[arg(long = "no-follow-renames", help_heading = "Filters")] + pub no_follow_renames: bool, + + /// Maximum trace chains to display + #[arg( + short = 'n', + long = "limit", + default_value = "20", + help_heading = "Output" + )] + pub limit: usize, +} + +#[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore count issues # Total issues in local database + lore count notes --for mr # Notes on merge requests only + lore count discussions --for issue # Discussions on issues only")] +pub struct CountArgs { + /// Entity type to count (issues, mrs, discussions, notes, events) + #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] + pub entity: String, + + /// Parent type filter: issue or mr (for discussions/notes) + #[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])] + pub for_entity: Option, +} + +#[derive(Parser)] +pub struct CronArgs { + #[command(subcommand)] + pub action: CronAction, +} + +#[derive(Subcommand)] +pub enum CronAction { + /// Install cron job for automatic syncing + Install { + /// Sync interval in minutes (default: 8) + #[arg(long, default_value = "8")] + interval: u32, + }, + + /// Remove cron job + Uninstall, + + /// Show current cron configuration + Status, +} + +#[derive(Args)] +pub struct TokenArgs { + #[command(subcommand)] + pub action: TokenAction, +} + +#[derive(Subcommand)] +pub enum TokenAction { + /// Store a GitLab token in the config file + Set { + /// Token value (reads from stdin if omitted in non-interactive mode) + #[arg(long)] + token: Option, + }, + + /// Show the current token (masked by default) + Show { + /// Show the full unmasked token + #[arg(long)] + unmask: bool, + }, +} diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 4b5d2bc..114c46b 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -6,8 +6,8 @@ use crate::Config; use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; -use crate::core::events_db::{self, EventCounts}; use crate::core::paths::get_db_path; +use crate::ingestion::storage::events::{EventCounts, count_events}; pub struct CountResult { pub entity: String, @@ -208,7 +208,7 @@ struct CountJsonBreakdown { pub fn run_count_events(config: &Config) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; - events_db::count_events(&conn) + count_events(&conn) } #[derive(Serialize)] diff --git a/src/cli/commands/ingest/mod.rs b/src/cli/commands/ingest/mod.rs new file mode 100644 index 0000000..169bc08 --- /dev/null +++ b/src/cli/commands/ingest/mod.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::cli::render::Theme; +use indicatif::{ProgressBar, ProgressStyle}; +use rusqlite::Connection; +use serde::Serialize; + +use tracing::Instrument; + +use crate::Config; +use crate::cli::robot::RobotMeta; +use crate::core::db::create_connection; +use crate::core::error::{LoreError, Result}; +use crate::core::lock::{AppLock, LockOptions}; +use crate::core::paths::get_db_path; +use crate::core::project::resolve_project; +use crate::core::shutdown::ShutdownSignal; +use crate::gitlab::GitLabClient; +use crate::ingestion::{ + IngestMrProjectResult, IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress, + ingest_project_merge_requests_with_progress, +}; + +include!("run.rs"); +include!("render.rs"); diff --git a/src/cli/commands/ingest/render.rs b/src/cli/commands/ingest/render.rs new file mode 100644 index 0000000..2568bce --- /dev/null +++ b/src/cli/commands/ingest/render.rs @@ -0,0 +1,331 @@ +fn print_issue_project_summary(path: &str, result: &IngestProjectResult) { + let labels_str = if result.labels_created > 0 { + format!(", {} new labels", result.labels_created) + } else { + String::new() + }; + + println!( + " {}: {} issues fetched{}", + Theme::info().render(path), + result.issues_upserted, + labels_str + ); + + if result.issues_synced_discussions > 0 { + println!( + " {} issues -> {} discussions, {} notes", + result.issues_synced_discussions, result.discussions_fetched, result.notes_upserted + ); + } + + if result.issues_skipped_discussion_sync > 0 { + println!( + " {} unchanged issues (discussion sync skipped)", + Theme::dim().render(&result.issues_skipped_discussion_sync.to_string()) + ); + } +} + +fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) { + let labels_str = if result.labels_created > 0 { + format!(", {} new labels", result.labels_created) + } else { + String::new() + }; + + let assignees_str = if result.assignees_linked > 0 || result.reviewers_linked > 0 { + format!( + ", {} assignees, {} reviewers", + result.assignees_linked, result.reviewers_linked + ) + } else { + String::new() + }; + + println!( + " {}: {} MRs fetched{}{}", + Theme::info().render(path), + result.mrs_upserted, + labels_str, + assignees_str + ); + + if result.mrs_synced_discussions > 0 { + let diffnotes_str = if result.diffnotes_count > 0 { + format!(" ({} diff notes)", result.diffnotes_count) + } else { + String::new() + }; + println!( + " {} MRs -> {} discussions, {} notes{}", + result.mrs_synced_discussions, + result.discussions_fetched, + result.notes_upserted, + diffnotes_str + ); + } + + if result.mrs_skipped_discussion_sync > 0 { + println!( + " {} unchanged MRs (discussion sync skipped)", + Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string()) + ); + } +} + +#[derive(Serialize)] +struct IngestJsonOutput { + ok: bool, + data: IngestJsonData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct IngestJsonData { + resource_type: String, + projects_synced: usize, + #[serde(skip_serializing_if = "Option::is_none")] + issues: Option, + #[serde(skip_serializing_if = "Option::is_none")] + merge_requests: Option, + labels_created: usize, + discussions_fetched: usize, + notes_upserted: usize, + resource_events_fetched: usize, + resource_events_failed: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + status_enrichment: Vec, + status_enrichment_errors: usize, +} + +#[derive(Serialize)] +struct StatusEnrichmentJson { + mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + seen: usize, + enriched: usize, + cleared: usize, + without_widget: usize, + partial_errors: usize, + #[serde(skip_serializing_if = "Option::is_none")] + first_partial_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct IngestIssueStats { + fetched: usize, + upserted: usize, + synced_discussions: usize, + skipped_discussion_sync: usize, +} + +#[derive(Serialize)] +struct IngestMrStats { + fetched: usize, + upserted: usize, + synced_discussions: usize, + skipped_discussion_sync: usize, + assignees_linked: usize, + reviewers_linked: usize, + diffnotes_count: usize, +} + +pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) { + let (issues, merge_requests) = if result.resource_type == "issues" { + ( + Some(IngestIssueStats { + fetched: result.issues_fetched, + upserted: result.issues_upserted, + synced_discussions: result.issues_synced_discussions, + skipped_discussion_sync: result.issues_skipped_discussion_sync, + }), + None, + ) + } else { + ( + None, + Some(IngestMrStats { + fetched: result.mrs_fetched, + upserted: result.mrs_upserted, + synced_discussions: result.mrs_synced_discussions, + skipped_discussion_sync: result.mrs_skipped_discussion_sync, + assignees_linked: result.assignees_linked, + reviewers_linked: result.reviewers_linked, + diffnotes_count: result.diffnotes_count, + }), + ) + }; + + let status_enrichment: Vec = result + .status_enrichment_projects + .iter() + .map(|p| StatusEnrichmentJson { + mode: p.mode.clone(), + reason: p.reason.clone(), + seen: p.seen, + enriched: p.enriched, + cleared: p.cleared, + without_widget: p.without_widget, + partial_errors: p.partial_errors, + first_partial_error: p.first_partial_error.clone(), + error: p.error.clone(), + }) + .collect(); + + let output = IngestJsonOutput { + ok: true, + data: IngestJsonData { + resource_type: result.resource_type.clone(), + projects_synced: result.projects_synced, + issues, + merge_requests, + labels_created: result.labels_created, + discussions_fetched: result.discussions_fetched, + notes_upserted: result.notes_upserted, + resource_events_fetched: result.resource_events_fetched, + resource_events_failed: result.resource_events_failed, + status_enrichment, + status_enrichment_errors: result.status_enrichment_errors, + }, + meta: RobotMeta { elapsed_ms }, + }; + + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +pub fn print_ingest_summary(result: &IngestResult) { + println!(); + + if result.resource_type == "issues" { + println!( + "{}", + Theme::success().render(&format!( + "Total: {} issues, {} discussions, {} notes", + result.issues_upserted, result.discussions_fetched, result.notes_upserted + )) + ); + + if result.issues_skipped_discussion_sync > 0 { + println!( + "{}", + Theme::dim().render(&format!( + "Skipped discussion sync for {} unchanged issues.", + result.issues_skipped_discussion_sync + )) + ); + } + } else { + let diffnotes_str = if result.diffnotes_count > 0 { + format!(" ({} diff notes)", result.diffnotes_count) + } else { + String::new() + }; + + println!( + "{}", + Theme::success().render(&format!( + "Total: {} MRs, {} discussions, {} notes{}", + result.mrs_upserted, + result.discussions_fetched, + result.notes_upserted, + diffnotes_str + )) + ); + + if result.mrs_skipped_discussion_sync > 0 { + println!( + "{}", + Theme::dim().render(&format!( + "Skipped discussion sync for {} unchanged MRs.", + result.mrs_skipped_discussion_sync + )) + ); + } + } + + if result.resource_events_fetched > 0 || result.resource_events_failed > 0 { + println!( + " Resource events: {} fetched{}", + result.resource_events_fetched, + if result.resource_events_failed > 0 { + format!(", {} failed", result.resource_events_failed) + } else { + String::new() + } + ); + } +} + +pub fn print_dry_run_preview(preview: &DryRunPreview) { + println!( + "{} {}", + Theme::info().bold().render("Dry Run Preview"), + Theme::warning().render("(no changes will be made)") + ); + println!(); + + let type_label = if preview.resource_type == "issues" { + "issues" + } else { + "merge requests" + }; + + println!(" Resource type: {}", Theme::bold().render(type_label)); + println!( + " Sync mode: {}", + if preview.sync_mode == "full" { + Theme::warning().render("full (all data will be re-fetched)") + } else { + Theme::success().render("incremental (only changes since last sync)") + } + ); + println!(" Projects: {}", preview.projects.len()); + println!(); + + println!("{}", Theme::info().bold().render("Projects to sync:")); + for project in &preview.projects { + let sync_status = if !project.has_cursor { + Theme::warning().render("initial sync") + } else { + Theme::success().render("incremental") + }; + + println!( + " {} ({})", + Theme::bold().render(&project.path), + sync_status + ); + println!(" Existing {}: {}", type_label, project.existing_count); + + if let Some(ref last_synced) = project.last_synced { + println!(" Last synced: {}", last_synced); + } + } +} + +#[derive(Serialize)] +struct DryRunJsonOutput { + ok: bool, + dry_run: bool, + data: DryRunPreview, +} + +pub fn print_dry_run_preview_json(preview: &DryRunPreview) { + let output = DryRunJsonOutput { + ok: true, + dry_run: true, + data: preview.clone(), + }; + + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest/run.rs similarity index 74% rename from src/cli/commands/ingest.rs rename to src/cli/commands/ingest/run.rs index 2604298..1fa2240 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest/run.rs @@ -1,27 +1,3 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use crate::cli::render::Theme; -use indicatif::{ProgressBar, ProgressStyle}; -use rusqlite::Connection; -use serde::Serialize; - -use tracing::Instrument; - -use crate::Config; -use crate::cli::robot::RobotMeta; -use crate::core::db::create_connection; -use crate::core::error::{LoreError, Result}; -use crate::core::lock::{AppLock, LockOptions}; -use crate::core::paths::get_db_path; -use crate::core::project::resolve_project; -use crate::core::shutdown::ShutdownSignal; -use crate::gitlab::GitLabClient; -use crate::ingestion::{ - IngestMrProjectResult, IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress, - ingest_project_merge_requests_with_progress, -}; - #[derive(Default)] pub struct IngestResult { pub resource_type: String, @@ -783,334 +759,3 @@ fn get_projects_to_sync( Ok(projects) } -fn print_issue_project_summary(path: &str, result: &IngestProjectResult) { - let labels_str = if result.labels_created > 0 { - format!(", {} new labels", result.labels_created) - } else { - String::new() - }; - - println!( - " {}: {} issues fetched{}", - Theme::info().render(path), - result.issues_upserted, - labels_str - ); - - if result.issues_synced_discussions > 0 { - println!( - " {} issues -> {} discussions, {} notes", - result.issues_synced_discussions, result.discussions_fetched, result.notes_upserted - ); - } - - if result.issues_skipped_discussion_sync > 0 { - println!( - " {} unchanged issues (discussion sync skipped)", - Theme::dim().render(&result.issues_skipped_discussion_sync.to_string()) - ); - } -} - -fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) { - let labels_str = if result.labels_created > 0 { - format!(", {} new labels", result.labels_created) - } else { - String::new() - }; - - let assignees_str = if result.assignees_linked > 0 || result.reviewers_linked > 0 { - format!( - ", {} assignees, {} reviewers", - result.assignees_linked, result.reviewers_linked - ) - } else { - String::new() - }; - - println!( - " {}: {} MRs fetched{}{}", - Theme::info().render(path), - result.mrs_upserted, - labels_str, - assignees_str - ); - - if result.mrs_synced_discussions > 0 { - let diffnotes_str = if result.diffnotes_count > 0 { - format!(" ({} diff notes)", result.diffnotes_count) - } else { - String::new() - }; - println!( - " {} MRs -> {} discussions, {} notes{}", - result.mrs_synced_discussions, - result.discussions_fetched, - result.notes_upserted, - diffnotes_str - ); - } - - if result.mrs_skipped_discussion_sync > 0 { - println!( - " {} unchanged MRs (discussion sync skipped)", - Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string()) - ); - } -} - -#[derive(Serialize)] -struct IngestJsonOutput { - ok: bool, - data: IngestJsonData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct IngestJsonData { - resource_type: String, - projects_synced: usize, - #[serde(skip_serializing_if = "Option::is_none")] - issues: Option, - #[serde(skip_serializing_if = "Option::is_none")] - merge_requests: Option, - labels_created: usize, - discussions_fetched: usize, - notes_upserted: usize, - resource_events_fetched: usize, - resource_events_failed: usize, - #[serde(skip_serializing_if = "Vec::is_empty")] - status_enrichment: Vec, - status_enrichment_errors: usize, -} - -#[derive(Serialize)] -struct StatusEnrichmentJson { - mode: String, - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option, - seen: usize, - enriched: usize, - cleared: usize, - without_widget: usize, - partial_errors: usize, - #[serde(skip_serializing_if = "Option::is_none")] - first_partial_error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Serialize)] -struct IngestIssueStats { - fetched: usize, - upserted: usize, - synced_discussions: usize, - skipped_discussion_sync: usize, -} - -#[derive(Serialize)] -struct IngestMrStats { - fetched: usize, - upserted: usize, - synced_discussions: usize, - skipped_discussion_sync: usize, - assignees_linked: usize, - reviewers_linked: usize, - diffnotes_count: usize, -} - -pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) { - let (issues, merge_requests) = if result.resource_type == "issues" { - ( - Some(IngestIssueStats { - fetched: result.issues_fetched, - upserted: result.issues_upserted, - synced_discussions: result.issues_synced_discussions, - skipped_discussion_sync: result.issues_skipped_discussion_sync, - }), - None, - ) - } else { - ( - None, - Some(IngestMrStats { - fetched: result.mrs_fetched, - upserted: result.mrs_upserted, - synced_discussions: result.mrs_synced_discussions, - skipped_discussion_sync: result.mrs_skipped_discussion_sync, - assignees_linked: result.assignees_linked, - reviewers_linked: result.reviewers_linked, - diffnotes_count: result.diffnotes_count, - }), - ) - }; - - let status_enrichment: Vec = result - .status_enrichment_projects - .iter() - .map(|p| StatusEnrichmentJson { - mode: p.mode.clone(), - reason: p.reason.clone(), - seen: p.seen, - enriched: p.enriched, - cleared: p.cleared, - without_widget: p.without_widget, - partial_errors: p.partial_errors, - first_partial_error: p.first_partial_error.clone(), - error: p.error.clone(), - }) - .collect(); - - let output = IngestJsonOutput { - ok: true, - data: IngestJsonData { - resource_type: result.resource_type.clone(), - projects_synced: result.projects_synced, - issues, - merge_requests, - labels_created: result.labels_created, - discussions_fetched: result.discussions_fetched, - notes_upserted: result.notes_upserted, - resource_events_fetched: result.resource_events_fetched, - resource_events_failed: result.resource_events_failed, - status_enrichment, - status_enrichment_errors: result.status_enrichment_errors, - }, - meta: RobotMeta { elapsed_ms }, - }; - - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -pub fn print_ingest_summary(result: &IngestResult) { - println!(); - - if result.resource_type == "issues" { - println!( - "{}", - Theme::success().render(&format!( - "Total: {} issues, {} discussions, {} notes", - result.issues_upserted, result.discussions_fetched, result.notes_upserted - )) - ); - - if result.issues_skipped_discussion_sync > 0 { - println!( - "{}", - Theme::dim().render(&format!( - "Skipped discussion sync for {} unchanged issues.", - result.issues_skipped_discussion_sync - )) - ); - } - } else { - let diffnotes_str = if result.diffnotes_count > 0 { - format!(" ({} diff notes)", result.diffnotes_count) - } else { - String::new() - }; - - println!( - "{}", - Theme::success().render(&format!( - "Total: {} MRs, {} discussions, {} notes{}", - result.mrs_upserted, - result.discussions_fetched, - result.notes_upserted, - diffnotes_str - )) - ); - - if result.mrs_skipped_discussion_sync > 0 { - println!( - "{}", - Theme::dim().render(&format!( - "Skipped discussion sync for {} unchanged MRs.", - result.mrs_skipped_discussion_sync - )) - ); - } - } - - if result.resource_events_fetched > 0 || result.resource_events_failed > 0 { - println!( - " Resource events: {} fetched{}", - result.resource_events_fetched, - if result.resource_events_failed > 0 { - format!(", {} failed", result.resource_events_failed) - } else { - String::new() - } - ); - } -} - -pub fn print_dry_run_preview(preview: &DryRunPreview) { - println!( - "{} {}", - Theme::info().bold().render("Dry Run Preview"), - Theme::warning().render("(no changes will be made)") - ); - println!(); - - let type_label = if preview.resource_type == "issues" { - "issues" - } else { - "merge requests" - }; - - println!(" Resource type: {}", Theme::bold().render(type_label)); - println!( - " Sync mode: {}", - if preview.sync_mode == "full" { - Theme::warning().render("full (all data will be re-fetched)") - } else { - Theme::success().render("incremental (only changes since last sync)") - } - ); - println!(" Projects: {}", preview.projects.len()); - println!(); - - println!("{}", Theme::info().bold().render("Projects to sync:")); - for project in &preview.projects { - let sync_status = if !project.has_cursor { - Theme::warning().render("initial sync") - } else { - Theme::success().render("incremental") - }; - - println!( - " {} ({})", - Theme::bold().render(&project.path), - sync_status - ); - println!(" Existing {}: {}", type_label, project.existing_count); - - if let Some(ref last_synced) = project.last_synced { - println!(" Last synced: {}", last_synced); - } - } -} - -#[derive(Serialize)] -struct DryRunJsonOutput { - ok: bool, - dry_run: bool, - data: DryRunPreview, -} - -pub fn print_dry_run_preview_json(preview: &DryRunPreview) { - let output = DryRunJsonOutput { - ok: true, - dry_run: true, - data: preview.clone(), - }; - - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs deleted file mode 100644 index e3adb39..0000000 --- a/src/cli/commands/list.rs +++ /dev/null @@ -1,1383 +0,0 @@ -use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme}; -use rusqlite::Connection; -use serde::Serialize; - -use crate::Config; -use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields}; -use crate::core::db::create_connection; -use crate::core::error::{LoreError, Result}; -use crate::core::path_resolver::escape_like as note_escape_like; -use crate::core::paths::get_db_path; -use crate::core::project::resolve_project; -use crate::core::time::{ms_to_iso, parse_since}; - -#[derive(Debug, Serialize)] -pub struct IssueListRow { - pub iid: i64, - pub title: String, - pub state: String, - pub author_username: String, - pub created_at: i64, - pub updated_at: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub web_url: Option, - pub project_path: String, - pub labels: Vec, - pub assignees: Vec, - pub discussion_count: i64, - pub unresolved_count: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_name: Option, - #[serde(skip_serializing)] - pub status_category: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_icon_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_synced_at: Option, -} - -#[derive(Serialize)] -pub struct IssueListRowJson { - pub iid: i64, - pub title: String, - pub state: String, - pub author_username: String, - pub labels: Vec, - pub assignees: Vec, - pub discussion_count: i64, - pub unresolved_count: i64, - pub created_at_iso: String, - pub updated_at_iso: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub web_url: Option, - pub project_path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_name: Option, - #[serde(skip_serializing)] - pub status_category: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_icon_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status_synced_at_iso: Option, -} - -impl From<&IssueListRow> for IssueListRowJson { - fn from(row: &IssueListRow) -> Self { - Self { - iid: row.iid, - title: row.title.clone(), - state: row.state.clone(), - author_username: row.author_username.clone(), - labels: row.labels.clone(), - assignees: row.assignees.clone(), - discussion_count: row.discussion_count, - unresolved_count: row.unresolved_count, - created_at_iso: ms_to_iso(row.created_at), - updated_at_iso: ms_to_iso(row.updated_at), - web_url: row.web_url.clone(), - project_path: row.project_path.clone(), - status_name: row.status_name.clone(), - status_category: row.status_category.clone(), - status_color: row.status_color.clone(), - status_icon_name: row.status_icon_name.clone(), - status_synced_at_iso: row.status_synced_at.map(ms_to_iso), - } - } -} - -#[derive(Serialize)] -pub struct ListResult { - pub issues: Vec, - pub total_count: usize, - pub available_statuses: Vec, -} - -#[derive(Serialize)] -pub struct ListResultJson { - pub issues: Vec, - pub total_count: usize, - pub showing: usize, -} - -impl From<&ListResult> for ListResultJson { - fn from(result: &ListResult) -> Self { - Self { - issues: result.issues.iter().map(IssueListRowJson::from).collect(), - total_count: result.total_count, - showing: result.issues.len(), - } - } -} - -#[derive(Debug, Serialize)] -pub struct MrListRow { - pub iid: i64, - pub title: String, - pub state: String, - pub draft: bool, - pub author_username: String, - pub source_branch: String, - pub target_branch: String, - pub created_at: i64, - pub updated_at: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub web_url: Option, - pub project_path: String, - pub labels: Vec, - pub assignees: Vec, - pub reviewers: Vec, - pub discussion_count: i64, - pub unresolved_count: i64, -} - -#[derive(Serialize)] -pub struct MrListRowJson { - pub iid: i64, - pub title: String, - pub state: String, - pub draft: bool, - pub author_username: String, - pub source_branch: String, - pub target_branch: String, - pub labels: Vec, - pub assignees: Vec, - pub reviewers: Vec, - pub discussion_count: i64, - pub unresolved_count: i64, - pub created_at_iso: String, - pub updated_at_iso: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub web_url: Option, - pub project_path: String, -} - -impl From<&MrListRow> for MrListRowJson { - fn from(row: &MrListRow) -> Self { - Self { - iid: row.iid, - title: row.title.clone(), - state: row.state.clone(), - draft: row.draft, - author_username: row.author_username.clone(), - source_branch: row.source_branch.clone(), - target_branch: row.target_branch.clone(), - labels: row.labels.clone(), - assignees: row.assignees.clone(), - reviewers: row.reviewers.clone(), - discussion_count: row.discussion_count, - unresolved_count: row.unresolved_count, - created_at_iso: ms_to_iso(row.created_at), - updated_at_iso: ms_to_iso(row.updated_at), - web_url: row.web_url.clone(), - project_path: row.project_path.clone(), - } - } -} - -#[derive(Serialize)] -pub struct MrListResult { - pub mrs: Vec, - pub total_count: usize, -} - -#[derive(Serialize)] -pub struct MrListResultJson { - pub mrs: Vec, - pub total_count: usize, - pub showing: usize, -} - -impl From<&MrListResult> for MrListResultJson { - fn from(result: &MrListResult) -> Self { - Self { - mrs: result.mrs.iter().map(MrListRowJson::from).collect(), - total_count: result.total_count, - showing: result.mrs.len(), - } - } -} - -pub struct ListFilters<'a> { - pub limit: usize, - pub project: Option<&'a str>, - pub state: Option<&'a str>, - pub author: Option<&'a str>, - pub assignee: Option<&'a str>, - pub labels: Option<&'a [String]>, - pub milestone: Option<&'a str>, - pub since: Option<&'a str>, - pub due_before: Option<&'a str>, - pub has_due_date: bool, - pub statuses: &'a [String], - pub sort: &'a str, - pub order: &'a str, -} - -pub struct MrListFilters<'a> { - pub limit: usize, - pub project: Option<&'a str>, - pub state: Option<&'a str>, - pub author: Option<&'a str>, - pub assignee: Option<&'a str>, - pub reviewer: Option<&'a str>, - pub labels: Option<&'a [String]>, - pub since: Option<&'a str>, - pub draft: bool, - pub no_draft: bool, - pub target_branch: Option<&'a str>, - pub source_branch: Option<&'a str>, - pub sort: &'a str, - pub order: &'a str, -} - -pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result { - let db_path = get_db_path(config.storage.db_path.as_deref()); - let conn = create_connection(&db_path)?; - - let mut result = query_issues(&conn, &filters)?; - result.available_statuses = query_available_statuses(&conn)?; - Ok(result) -} - -fn query_available_statuses(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT DISTINCT status_name FROM issues WHERE status_name IS NOT NULL ORDER BY status_name", - )?; - let statuses = stmt - .query_map([], |row| row.get::<_, String>(0))? - .collect::, _>>()?; - Ok(statuses) -} - -fn query_issues(conn: &Connection, filters: &ListFilters) -> Result { - let mut where_clauses = Vec::new(); - let mut params: Vec> = Vec::new(); - - if let Some(project) = filters.project { - let project_id = resolve_project(conn, project)?; - where_clauses.push("i.project_id = ?"); - params.push(Box::new(project_id)); - } - - if let Some(state) = filters.state - && state != "all" - { - where_clauses.push("i.state = ?"); - params.push(Box::new(state.to_string())); - } - - if let Some(author) = filters.author { - let username = author.strip_prefix('@').unwrap_or(author); - where_clauses.push("i.author_username = ?"); - params.push(Box::new(username.to_string())); - } - - if let Some(assignee) = filters.assignee { - let username = assignee.strip_prefix('@').unwrap_or(assignee); - where_clauses.push( - "EXISTS (SELECT 1 FROM issue_assignees ia - WHERE ia.issue_id = i.id AND ia.username = ?)", - ); - params.push(Box::new(username.to_string())); - } - - if let Some(since_str) = filters.since { - let cutoff_ms = parse_since(since_str).ok_or_else(|| { - LoreError::Other(format!( - "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", - since_str - )) - })?; - where_clauses.push("i.updated_at >= ?"); - params.push(Box::new(cutoff_ms)); - } - - if let Some(labels) = filters.labels { - for label in labels { - where_clauses.push( - "EXISTS (SELECT 1 FROM issue_labels il - JOIN labels l ON il.label_id = l.id - WHERE il.issue_id = i.id AND l.name = ?)", - ); - params.push(Box::new(label.clone())); - } - } - - if let Some(milestone) = filters.milestone { - where_clauses.push("i.milestone_title = ?"); - params.push(Box::new(milestone.to_string())); - } - - if let Some(due_before) = filters.due_before { - where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?"); - params.push(Box::new(due_before.to_string())); - } - - if filters.has_due_date { - where_clauses.push("i.due_date IS NOT NULL"); - } - - let status_in_clause; - if filters.statuses.len() == 1 { - where_clauses.push("i.status_name = ? COLLATE NOCASE"); - params.push(Box::new(filters.statuses[0].clone())); - } else if filters.statuses.len() > 1 { - let placeholders: Vec<&str> = filters.statuses.iter().map(|_| "?").collect(); - status_in_clause = format!( - "i.status_name COLLATE NOCASE IN ({})", - placeholders.join(", ") - ); - where_clauses.push(&status_in_clause); - for s in filters.statuses { - params.push(Box::new(s.clone())); - } - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - - let count_sql = format!( - "SELECT COUNT(*) FROM issues i - JOIN projects p ON i.project_id = p.id - {where_sql}" - ); - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; - let total_count = total_count as usize; - - let sort_column = match filters.sort { - "created" => "i.created_at", - "iid" => "i.iid", - _ => "i.updated_at", - }; - let order = if filters.order == "asc" { - "ASC" - } else { - "DESC" - }; - - let query_sql = format!( - "SELECT - i.iid, - i.title, - i.state, - i.author_username, - i.created_at, - i.updated_at, - i.web_url, - p.path_with_namespace, - (SELECT GROUP_CONCAT(l.name, X'1F') - FROM issue_labels il - JOIN labels l ON il.label_id = l.id - WHERE il.issue_id = i.id) AS labels_csv, - (SELECT GROUP_CONCAT(ia.username, X'1F') - FROM issue_assignees ia - WHERE ia.issue_id = i.id) AS assignees_csv, - (SELECT COUNT(*) FROM discussions d - 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, - i.status_name, - i.status_category, - i.status_color, - i.status_icon_name, - i.status_synced_at - FROM issues i - JOIN projects p ON i.project_id = p.id - {where_sql} - ORDER BY {sort_column} {order} - LIMIT ?" - ); - - params.push(Box::new(filters.limit as i64)); - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let mut stmt = conn.prepare(&query_sql)?; - let issues: Vec = stmt - .query_map(param_refs.as_slice(), |row| { - let labels_csv: Option = row.get(8)?; - let labels = labels_csv - .map(|s| s.split('\x1F').map(String::from).collect()) - .unwrap_or_default(); - - let assignees_csv: Option = row.get(9)?; - let assignees = assignees_csv - .map(|s| s.split('\x1F').map(String::from).collect()) - .unwrap_or_default(); - - Ok(IssueListRow { - iid: row.get(0)?, - title: row.get(1)?, - state: row.get(2)?, - author_username: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - web_url: row.get(6)?, - project_path: row.get(7)?, - labels, - assignees, - discussion_count: row.get(10)?, - unresolved_count: row.get(11)?, - status_name: row.get(12)?, - status_category: row.get(13)?, - status_color: row.get(14)?, - status_icon_name: row.get(15)?, - status_synced_at: row.get(16)?, - }) - })? - .collect::, _>>()?; - - Ok(ListResult { - issues, - total_count, - available_statuses: Vec::new(), - }) -} - -pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result { - let db_path = get_db_path(config.storage.db_path.as_deref()); - let conn = create_connection(&db_path)?; - - let result = query_mrs(&conn, &filters)?; - Ok(result) -} - -fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result { - let mut where_clauses = Vec::new(); - let mut params: Vec> = Vec::new(); - - if let Some(project) = filters.project { - let project_id = resolve_project(conn, project)?; - where_clauses.push("m.project_id = ?"); - params.push(Box::new(project_id)); - } - - if let Some(state) = filters.state - && state != "all" - { - where_clauses.push("m.state = ?"); - params.push(Box::new(state.to_string())); - } - - if let Some(author) = filters.author { - let username = author.strip_prefix('@').unwrap_or(author); - where_clauses.push("m.author_username = ?"); - params.push(Box::new(username.to_string())); - } - - if let Some(assignee) = filters.assignee { - let username = assignee.strip_prefix('@').unwrap_or(assignee); - where_clauses.push( - "EXISTS (SELECT 1 FROM mr_assignees ma - WHERE ma.merge_request_id = m.id AND ma.username = ?)", - ); - params.push(Box::new(username.to_string())); - } - - if let Some(reviewer) = filters.reviewer { - let username = reviewer.strip_prefix('@').unwrap_or(reviewer); - where_clauses.push( - "EXISTS (SELECT 1 FROM mr_reviewers mr - WHERE mr.merge_request_id = m.id AND mr.username = ?)", - ); - params.push(Box::new(username.to_string())); - } - - if let Some(since_str) = filters.since { - let cutoff_ms = parse_since(since_str).ok_or_else(|| { - LoreError::Other(format!( - "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", - since_str - )) - })?; - where_clauses.push("m.updated_at >= ?"); - params.push(Box::new(cutoff_ms)); - } - - if let Some(labels) = filters.labels { - for label in labels { - where_clauses.push( - "EXISTS (SELECT 1 FROM mr_labels ml - JOIN labels l ON ml.label_id = l.id - WHERE ml.merge_request_id = m.id AND l.name = ?)", - ); - params.push(Box::new(label.clone())); - } - } - - if filters.draft { - where_clauses.push("m.draft = 1"); - } else if filters.no_draft { - where_clauses.push("m.draft = 0"); - } - - if let Some(target_branch) = filters.target_branch { - where_clauses.push("m.target_branch = ?"); - params.push(Box::new(target_branch.to_string())); - } - - if let Some(source_branch) = filters.source_branch { - where_clauses.push("m.source_branch = ?"); - params.push(Box::new(source_branch.to_string())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - - let count_sql = format!( - "SELECT COUNT(*) FROM merge_requests m - JOIN projects p ON m.project_id = p.id - {where_sql}" - ); - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; - let total_count = total_count as usize; - - let sort_column = match filters.sort { - "created" => "m.created_at", - "iid" => "m.iid", - _ => "m.updated_at", - }; - let order = if filters.order == "asc" { - "ASC" - } else { - "DESC" - }; - - let query_sql = format!( - "SELECT - m.iid, - m.title, - m.state, - m.draft, - m.author_username, - m.source_branch, - m.target_branch, - m.created_at, - m.updated_at, - m.web_url, - p.path_with_namespace, - (SELECT GROUP_CONCAT(l.name, X'1F') - FROM mr_labels ml - JOIN labels l ON ml.label_id = l.id - WHERE ml.merge_request_id = m.id) AS labels_csv, - (SELECT GROUP_CONCAT(ma.username, X'1F') - FROM mr_assignees ma - WHERE ma.merge_request_id = m.id) AS assignees_csv, - (SELECT GROUP_CONCAT(mr.username, X'1F') - FROM mr_reviewers mr - WHERE mr.merge_request_id = m.id) AS reviewers_csv, - (SELECT COUNT(*) FROM discussions d - 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 - JOIN projects p ON m.project_id = p.id - {where_sql} - ORDER BY {sort_column} {order} - LIMIT ?" - ); - - params.push(Box::new(filters.limit as i64)); - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let mut stmt = conn.prepare(&query_sql)?; - let mrs: Vec = stmt - .query_map(param_refs.as_slice(), |row| { - let labels_csv: Option = row.get(11)?; - let labels = labels_csv - .map(|s| s.split('\x1F').map(String::from).collect()) - .unwrap_or_default(); - - let assignees_csv: Option = row.get(12)?; - let assignees = assignees_csv - .map(|s| s.split('\x1F').map(String::from).collect()) - .unwrap_or_default(); - - let reviewers_csv: Option = row.get(13)?; - let reviewers = reviewers_csv - .map(|s| s.split('\x1F').map(String::from).collect()) - .unwrap_or_default(); - - let draft_int: i64 = row.get(3)?; - - Ok(MrListRow { - iid: row.get(0)?, - title: row.get(1)?, - state: row.get(2)?, - draft: draft_int == 1, - author_username: row.get(4)?, - source_branch: row.get(5)?, - target_branch: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - web_url: row.get(9)?, - project_path: row.get(10)?, - labels, - assignees, - reviewers, - discussion_count: row.get(14)?, - unresolved_count: row.get(15)?, - }) - })? - .collect::, _>>()?; - - Ok(MrListResult { mrs, total_count }) -} - -fn format_assignees(assignees: &[String]) -> String { - if assignees.is_empty() { - return "-".to_string(); - } - - let max_shown = 2; - let shown: Vec = assignees - .iter() - .take(max_shown) - .map(|s| format!("@{}", render::truncate(s, 10))) - .collect(); - let overflow = assignees.len().saturating_sub(max_shown); - - if overflow > 0 { - format!("{} +{}", shown.join(", "), overflow) - } else { - shown.join(", ") - } -} - -fn format_discussions(total: i64, unresolved: i64) -> StyledCell { - if total == 0 { - return StyledCell::plain(String::new()); - } - - if unresolved > 0 { - let text = format!("{total}/"); - let warn = Theme::warning().render(&format!("{unresolved}!")); - StyledCell::plain(format!("{text}{warn}")) - } else { - StyledCell::plain(format!("{total}")) - } -} - -fn format_branches(target: &str, source: &str, max_width: usize) -> String { - let full = format!("{} <- {}", target, source); - render::truncate(&full, max_width) -} - -pub fn print_list_issues(result: &ListResult) { - if result.issues.is_empty() { - println!("No issues found."); - return; - } - - println!( - "{} {} of {}\n", - Theme::bold().render("Issues"), - result.issues.len(), - result.total_count - ); - - let has_any_status = result.issues.iter().any(|i| i.status_name.is_some()); - - let mut headers = vec!["IID", "Title", "State"]; - if has_any_status { - headers.push("Status"); - } - headers.extend(["Assignee", "Labels", "Disc", "Updated"]); - - let mut table = LoreTable::new().headers(&headers).align(0, Align::Right); - - for issue in &result.issues { - let title = render::truncate(&issue.title, 45); - let relative_time = render::format_relative_time_compact(issue.updated_at); - let labels = render::format_labels_bare(&issue.labels, 2); - let assignee = format_assignees(&issue.assignees); - let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); - - let (icon, state_style) = if issue.state == "opened" { - (Icons::issue_opened(), Theme::success()) - } else { - (Icons::issue_closed(), Theme::dim()) - }; - let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style); - - let mut row = vec![ - StyledCell::styled(format!("#{}", issue.iid), Theme::info()), - StyledCell::plain(title), - state_cell, - ]; - if has_any_status { - match &issue.status_name { - Some(status) => { - row.push(StyledCell::plain(render::style_with_hex( - status, - issue.status_color.as_deref(), - ))); - } - None => { - row.push(StyledCell::plain("")); - } - } - } - row.extend([ - StyledCell::styled(assignee, Theme::accent()), - StyledCell::styled(labels, Theme::warning()), - discussions, - StyledCell::styled(relative_time, Theme::dim()), - ]); - table.add_row(row); - } - - println!("{}", table.render()); -} - -pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { - let json_result = ListResultJson::from(result); - let output = serde_json::json!({ - "ok": true, - "data": json_result, - "meta": { - "elapsed_ms": elapsed_ms, - "available_statuses": result.available_statuses, - }, - }); - let mut output = output; - if let Some(f) = fields { - let expanded = expand_fields_preset(f, "issues"); - filter_fields(&mut output, "issues", &expanded); - } - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -pub fn open_issue_in_browser(result: &ListResult) -> Option { - let first_issue = result.issues.first()?; - let url = first_issue.web_url.as_ref()?; - - match open::that(url) { - Ok(()) => { - println!("Opened: {url}"); - Some(url.clone()) - } - Err(e) => { - eprintln!("Failed to open browser: {e}"); - None - } - } -} - -pub fn print_list_mrs(result: &MrListResult) { - if result.mrs.is_empty() { - println!("No merge requests found."); - return; - } - - println!( - "{} {} of {}\n", - Theme::bold().render("Merge Requests"), - result.mrs.len(), - result.total_count - ); - - let mut table = LoreTable::new() - .headers(&[ - "IID", "Title", "State", "Author", "Branches", "Disc", "Updated", - ]) - .align(0, Align::Right); - - for mr in &result.mrs { - let title = if mr.draft { - format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) - } else { - render::truncate(&mr.title, 45) - }; - - let relative_time = render::format_relative_time_compact(mr.updated_at); - let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); - let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); - - let (icon, style) = match mr.state.as_str() { - "opened" => (Icons::mr_opened(), Theme::success()), - "merged" => (Icons::mr_merged(), Theme::accent()), - "closed" => (Icons::mr_closed(), Theme::error()), - "locked" => (Icons::mr_opened(), Theme::warning()), - _ => (Icons::mr_opened(), Theme::dim()), - }; - let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style); - - table.add_row(vec![ - StyledCell::styled(format!("!{}", mr.iid), Theme::info()), - StyledCell::plain(title), - state_cell, - StyledCell::styled( - format!("@{}", render::truncate(&mr.author_username, 12)), - Theme::accent(), - ), - StyledCell::styled(branches, Theme::info()), - discussions, - StyledCell::styled(relative_time, Theme::dim()), - ]); - } - - println!("{}", table.render()); -} - -pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) { - let json_result = MrListResultJson::from(result); - let meta = RobotMeta { elapsed_ms }; - let output = serde_json::json!({ - "ok": true, - "data": json_result, - "meta": meta, - }); - let mut output = output; - if let Some(f) = fields { - let expanded = expand_fields_preset(f, "mrs"); - filter_fields(&mut output, "mrs", &expanded); - } - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -pub fn open_mr_in_browser(result: &MrListResult) -> Option { - let first_mr = result.mrs.first()?; - let url = first_mr.web_url.as_ref()?; - - match open::that(url) { - Ok(()) => { - println!("Opened: {url}"); - Some(url.clone()) - } - Err(e) => { - eprintln!("Failed to open browser: {e}"); - None - } - } -} - -// --------------------------------------------------------------------------- -// Note output formatting -// --------------------------------------------------------------------------- - -fn truncate_body(body: &str, max_len: usize) -> String { - if body.chars().count() <= max_len { - body.to_string() - } else { - let truncated: String = body.chars().take(max_len).collect(); - format!("{truncated}...") - } -} - -fn format_note_type(note_type: Option<&str>) -> &str { - match note_type { - Some("DiffNote") => "Diff", - Some("DiscussionNote") => "Disc", - _ => "-", - } -} - -fn format_note_path(path: Option<&str>, line: Option) -> String { - match (path, line) { - (Some(p), Some(l)) => format!("{p}:{l}"), - (Some(p), None) => p.to_string(), - _ => "-".to_string(), - } -} - -fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option) -> String { - match (noteable_type, parent_iid) { - (Some("Issue"), Some(iid)) => format!("Issue #{iid}"), - (Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"), - _ => "-".to_string(), - } -} - -pub fn print_list_notes(result: &NoteListResult) { - if result.notes.is_empty() { - println!("No notes found."); - return; - } - - println!( - "{} {} of {}\n", - Theme::bold().render("Notes"), - result.notes.len(), - result.total_count - ); - - let mut table = LoreTable::new() - .headers(&[ - "ID", - "Author", - "Type", - "Body", - "Path:Line", - "Parent", - "Created", - ]) - .align(0, Align::Right); - - for note in &result.notes { - let body = note - .body - .as_deref() - .map(|b| truncate_body(b, 60)) - .unwrap_or_default(); - let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); - let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid); - let relative_time = render::format_relative_time_compact(note.created_at); - let note_type = format_note_type(note.note_type.as_deref()); - - table.add_row(vec![ - StyledCell::styled(note.gitlab_id.to_string(), Theme::info()), - StyledCell::styled( - format!("@{}", render::truncate(¬e.author_username, 12)), - Theme::accent(), - ), - StyledCell::plain(note_type), - StyledCell::plain(body), - StyledCell::plain(path), - StyledCell::plain(parent), - StyledCell::styled(relative_time, Theme::dim()), - ]); - } - - println!("{}", table.render()); -} - -pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) { - let json_result = NoteListResultJson::from(result); - let meta = RobotMeta { elapsed_ms }; - let output = serde_json::json!({ - "ok": true, - "data": json_result, - "meta": meta, - }); - let mut output = output; - if let Some(f) = fields { - let expanded = expand_fields_preset(f, "notes"); - filter_fields(&mut output, "notes", &expanded); - } - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -// --------------------------------------------------------------------------- -// Note query layer -// --------------------------------------------------------------------------- - -#[derive(Debug, Serialize)] -pub struct NoteListRow { - pub id: i64, - pub gitlab_id: i64, - pub author_username: String, - pub body: Option, - pub note_type: Option, - pub is_system: bool, - pub created_at: i64, - pub updated_at: i64, - pub position_new_path: Option, - pub position_new_line: Option, - pub position_old_path: Option, - pub position_old_line: Option, - pub resolvable: bool, - pub resolved: bool, - pub resolved_by: Option, - pub noteable_type: Option, - pub parent_iid: Option, - pub parent_title: Option, - pub project_path: String, -} - -#[derive(Serialize)] -pub struct NoteListRowJson { - pub id: i64, - pub gitlab_id: i64, - pub author_username: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub body: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub note_type: Option, - pub is_system: bool, - pub created_at_iso: String, - pub updated_at_iso: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub position_new_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub position_new_line: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub position_old_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub position_old_line: Option, - pub resolvable: bool, - pub resolved: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub resolved_by: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub noteable_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_iid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_title: Option, - pub project_path: String, -} - -impl From<&NoteListRow> for NoteListRowJson { - fn from(row: &NoteListRow) -> Self { - Self { - id: row.id, - gitlab_id: row.gitlab_id, - author_username: row.author_username.clone(), - body: row.body.clone(), - note_type: row.note_type.clone(), - is_system: row.is_system, - created_at_iso: ms_to_iso(row.created_at), - updated_at_iso: ms_to_iso(row.updated_at), - position_new_path: row.position_new_path.clone(), - position_new_line: row.position_new_line, - position_old_path: row.position_old_path.clone(), - position_old_line: row.position_old_line, - resolvable: row.resolvable, - resolved: row.resolved, - resolved_by: row.resolved_by.clone(), - noteable_type: row.noteable_type.clone(), - parent_iid: row.parent_iid, - parent_title: row.parent_title.clone(), - project_path: row.project_path.clone(), - } - } -} - -#[derive(Debug)] -pub struct NoteListResult { - pub notes: Vec, - pub total_count: i64, -} - -#[derive(Serialize)] -pub struct NoteListResultJson { - pub notes: Vec, - pub total_count: i64, - pub showing: usize, -} - -impl From<&NoteListResult> for NoteListResultJson { - fn from(result: &NoteListResult) -> Self { - Self { - notes: result.notes.iter().map(NoteListRowJson::from).collect(), - total_count: result.total_count, - showing: result.notes.len(), - } - } -} - -pub struct NoteListFilters { - pub limit: usize, - pub project: Option, - pub author: Option, - pub note_type: Option, - pub include_system: bool, - pub for_issue_iid: Option, - pub for_mr_iid: Option, - pub note_id: Option, - pub gitlab_note_id: Option, - pub discussion_id: Option, - pub since: Option, - pub until: Option, - pub path: Option, - pub contains: Option, - pub resolution: Option, - pub sort: String, - pub order: String, -} - -pub fn query_notes( - conn: &Connection, - filters: &NoteListFilters, - config: &Config, -) -> Result { - let mut where_clauses: Vec = Vec::new(); - let mut params: Vec> = Vec::new(); - - // Project filter - if let Some(ref project) = filters.project { - let project_id = resolve_project(conn, project)?; - where_clauses.push("n.project_id = ?".to_string()); - params.push(Box::new(project_id)); - } - - // Author filter (case-insensitive, strip leading @) - if let Some(ref author) = filters.author { - let username = author.strip_prefix('@').unwrap_or(author); - where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string()); - params.push(Box::new(username.to_string())); - } - - // Note type filter - if let Some(ref note_type) = filters.note_type { - where_clauses.push("n.note_type = ?".to_string()); - params.push(Box::new(note_type.clone())); - } - - // System note filter (default: exclude system notes) - if !filters.include_system { - where_clauses.push("n.is_system = 0".to_string()); - } - - // Since filter - let since_ms = if let Some(ref since_str) = filters.since { - let ms = parse_since(since_str).ok_or_else(|| { - LoreError::Other(format!( - "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", - since_str - )) - })?; - where_clauses.push("n.created_at >= ?".to_string()); - params.push(Box::new(ms)); - Some(ms) - } else { - None - }; - - // Until filter (end of day for date-only input) - if let Some(ref until_str) = filters.until { - let until_ms = if until_str.len() == 10 - && until_str.chars().filter(|&c| c == '-').count() == 2 - { - // Date-only: use end of day 23:59:59.999 - let iso_full = format!("{until_str}T23:59:59.999Z"); - crate::core::time::iso_to_ms(&iso_full).ok_or_else(|| { - LoreError::Other(format!( - "Invalid --until value '{}'. Use YYYY-MM-DD or relative format.", - until_str - )) - })? - } else { - parse_since(until_str).ok_or_else(|| { - LoreError::Other(format!( - "Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", - until_str - )) - })? - }; - - // Validate since <= until - if let Some(s) = since_ms - && s > until_ms - { - return Err(LoreError::Other( - "Invalid time window: --since is after --until.".to_string(), - )); - } - - where_clauses.push("n.created_at <= ?".to_string()); - params.push(Box::new(until_ms)); - } - - // Path filter (trailing / = prefix match, else exact) - if let Some(ref path) = filters.path { - if let Some(prefix) = path.strip_suffix('/') { - let escaped = note_escape_like(prefix); - where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string()); - params.push(Box::new(format!("{escaped}%"))); - } else { - where_clauses.push("n.position_new_path = ?".to_string()); - params.push(Box::new(path.clone())); - } - } - - // Contains filter (LIKE %term% on body, case-insensitive) - if let Some(ref contains) = filters.contains { - let escaped = note_escape_like(contains); - where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string()); - params.push(Box::new(format!("%{escaped}%"))); - } - - // Resolution filter - if let Some(ref resolution) = filters.resolution { - match resolution.as_str() { - "unresolved" => { - where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string()); - } - "resolved" => { - where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string()); - } - other => { - return Err(LoreError::Other(format!( - "Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.", - other - ))); - } - } - } - - // For-issue-iid filter (requires project context) - if let Some(iid) = filters.for_issue_iid { - let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| { - LoreError::Other( - "Cannot filter by issue IID without a project context. Use --project or set defaultProject in config." - .to_string(), - ) - })?; - let project_id = resolve_project(conn, project_str)?; - where_clauses.push( - "d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(), - ); - params.push(Box::new(project_id)); - params.push(Box::new(iid)); - } - - // For-mr-iid filter (requires project context) - if let Some(iid) = filters.for_mr_iid { - let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| { - LoreError::Other( - "Cannot filter by MR IID without a project context. Use --project or set defaultProject in config." - .to_string(), - ) - })?; - let project_id = resolve_project(conn, project_str)?; - where_clauses.push( - "d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)" - .to_string(), - ); - params.push(Box::new(project_id)); - params.push(Box::new(iid)); - } - - // Note ID filter - if let Some(id) = filters.note_id { - where_clauses.push("n.id = ?".to_string()); - params.push(Box::new(id)); - } - - // GitLab note ID filter - if let Some(gitlab_id) = filters.gitlab_note_id { - where_clauses.push("n.gitlab_id = ?".to_string()); - params.push(Box::new(gitlab_id)); - } - - // Discussion ID filter - if let Some(ref disc_id) = filters.discussion_id { - where_clauses.push("d.gitlab_discussion_id = ?".to_string()); - params.push(Box::new(disc_id.clone())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - - // Count query - let count_sql = format!( - "SELECT COUNT(*) FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN projects p ON n.project_id = p.id - LEFT JOIN issues i ON d.issue_id = i.id - LEFT JOIN merge_requests m ON d.merge_request_id = m.id - {where_sql}" - ); - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; - - // Sort + order - let sort_column = match filters.sort.as_str() { - "updated" => "n.updated_at", - _ => "n.created_at", - }; - let order = if filters.order == "asc" { - "ASC" - } else { - "DESC" - }; - - let query_sql = format!( - "SELECT - n.id, - n.gitlab_id, - n.author_username, - n.body, - n.note_type, - n.is_system, - n.created_at, - n.updated_at, - n.position_new_path, - n.position_new_line, - n.position_old_path, - n.position_old_line, - n.resolvable, - n.resolved, - n.resolved_by, - d.noteable_type, - COALESCE(i.iid, m.iid) AS parent_iid, - COALESCE(i.title, m.title) AS parent_title, - p.path_with_namespace AS project_path - FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN projects p ON n.project_id = p.id - LEFT JOIN issues i ON d.issue_id = i.id - LEFT JOIN merge_requests m ON d.merge_request_id = m.id - {where_sql} - ORDER BY {sort_column} {order}, n.id {order} - LIMIT ?" - ); - - params.push(Box::new(filters.limit as i64)); - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let mut stmt = conn.prepare(&query_sql)?; - let notes: Vec = stmt - .query_map(param_refs.as_slice(), |row| { - let is_system_int: i64 = row.get(5)?; - let resolvable_int: i64 = row.get(12)?; - let resolved_int: i64 = row.get(13)?; - - Ok(NoteListRow { - id: row.get(0)?, - gitlab_id: row.get(1)?, - author_username: row.get::<_, Option>(2)?.unwrap_or_default(), - body: row.get(3)?, - note_type: row.get(4)?, - is_system: is_system_int == 1, - created_at: row.get(6)?, - updated_at: row.get(7)?, - position_new_path: row.get(8)?, - position_new_line: row.get(9)?, - position_old_path: row.get(10)?, - position_old_line: row.get(11)?, - resolvable: resolvable_int == 1, - resolved: resolved_int == 1, - resolved_by: row.get(14)?, - noteable_type: row.get(15)?, - parent_iid: row.get(16)?, - parent_title: row.get(17)?, - project_path: row.get(18)?, - }) - })? - .collect::, _>>()?; - - Ok(NoteListResult { notes, total_count }) -} - -#[cfg(test)] -#[path = "list_tests.rs"] -mod tests; diff --git a/src/cli/commands/list/issues.rs b/src/cli/commands/list/issues.rs new file mode 100644 index 0000000..6b1ffa2 --- /dev/null +++ b/src/cli/commands/list/issues.rs @@ -0,0 +1,443 @@ +use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::Config; +use crate::cli::robot::{expand_fields_preset, filter_fields}; +use crate::core::db::create_connection; +use crate::core::error::{LoreError, Result}; +use crate::core::paths::get_db_path; +use crate::core::project::resolve_project; +use crate::core::time::{ms_to_iso, parse_since}; + +use super::render_helpers::{format_assignees, format_discussions}; + +#[derive(Debug, Serialize)] +pub struct IssueListRow { + pub iid: i64, + pub title: String, + pub state: String, + pub author_username: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub assignees: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_name: Option, + #[serde(skip_serializing)] + pub status_category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_icon_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_synced_at: Option, +} + +#[derive(Serialize)] +pub struct IssueListRowJson { + pub iid: i64, + pub title: String, + pub state: String, + pub author_username: String, + pub labels: Vec, + pub assignees: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, + pub created_at_iso: String, + pub updated_at_iso: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_name: Option, + #[serde(skip_serializing)] + pub status_category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_icon_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_synced_at_iso: Option, +} + +impl From<&IssueListRow> for IssueListRowJson { + fn from(row: &IssueListRow) -> Self { + Self { + iid: row.iid, + title: row.title.clone(), + state: row.state.clone(), + author_username: row.author_username.clone(), + labels: row.labels.clone(), + assignees: row.assignees.clone(), + discussion_count: row.discussion_count, + unresolved_count: row.unresolved_count, + created_at_iso: ms_to_iso(row.created_at), + updated_at_iso: ms_to_iso(row.updated_at), + web_url: row.web_url.clone(), + project_path: row.project_path.clone(), + status_name: row.status_name.clone(), + status_category: row.status_category.clone(), + status_color: row.status_color.clone(), + status_icon_name: row.status_icon_name.clone(), + status_synced_at_iso: row.status_synced_at.map(ms_to_iso), + } + } +} + +#[derive(Serialize)] +pub struct ListResult { + pub issues: Vec, + pub total_count: usize, + pub available_statuses: Vec, +} + +#[derive(Serialize)] +pub struct ListResultJson { + pub issues: Vec, + pub total_count: usize, + pub showing: usize, +} + +impl From<&ListResult> for ListResultJson { + fn from(result: &ListResult) -> Self { + Self { + issues: result.issues.iter().map(IssueListRowJson::from).collect(), + total_count: result.total_count, + showing: result.issues.len(), + } + } +} + +pub struct ListFilters<'a> { + pub limit: usize, + pub project: Option<&'a str>, + pub state: Option<&'a str>, + pub author: Option<&'a str>, + pub assignee: Option<&'a str>, + pub labels: Option<&'a [String]>, + pub milestone: Option<&'a str>, + pub since: Option<&'a str>, + pub due_before: Option<&'a str>, + pub has_due_date: bool, + pub statuses: &'a [String], + pub sort: &'a str, + pub order: &'a str, +} + +pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let mut result = query_issues(&conn, &filters)?; + result.available_statuses = query_available_statuses(&conn)?; + Ok(result) +} + +fn query_available_statuses(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT DISTINCT status_name FROM issues WHERE status_name IS NOT NULL ORDER BY status_name", + )?; + let statuses = stmt + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(statuses) +} + +fn query_issues(conn: &Connection, filters: &ListFilters) -> Result { + let mut where_clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(project) = filters.project { + let project_id = resolve_project(conn, project)?; + where_clauses.push("i.project_id = ?"); + params.push(Box::new(project_id)); + } + + if let Some(state) = filters.state + && state != "all" + { + where_clauses.push("i.state = ?"); + params.push(Box::new(state.to_string())); + } + + if let Some(author) = filters.author { + let username = author.strip_prefix('@').unwrap_or(author); + where_clauses.push("i.author_username = ?"); + params.push(Box::new(username.to_string())); + } + + if let Some(assignee) = filters.assignee { + let username = assignee.strip_prefix('@').unwrap_or(assignee); + where_clauses.push( + "EXISTS (SELECT 1 FROM issue_assignees ia + WHERE ia.issue_id = i.id AND ia.username = ?)", + ); + params.push(Box::new(username.to_string())); + } + + if let Some(since_str) = filters.since { + let cutoff_ms = parse_since(since_str).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", + since_str + )) + })?; + where_clauses.push("i.updated_at >= ?"); + params.push(Box::new(cutoff_ms)); + } + + if let Some(labels) = filters.labels { + for label in labels { + where_clauses.push( + "EXISTS (SELECT 1 FROM issue_labels il + JOIN labels l ON il.label_id = l.id + WHERE il.issue_id = i.id AND l.name = ?)", + ); + params.push(Box::new(label.clone())); + } + } + + if let Some(milestone) = filters.milestone { + where_clauses.push("i.milestone_title = ?"); + params.push(Box::new(milestone.to_string())); + } + + if let Some(due_before) = filters.due_before { + where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?"); + params.push(Box::new(due_before.to_string())); + } + + if filters.has_due_date { + where_clauses.push("i.due_date IS NOT NULL"); + } + + let status_in_clause; + if filters.statuses.len() == 1 { + where_clauses.push("i.status_name = ? COLLATE NOCASE"); + params.push(Box::new(filters.statuses[0].clone())); + } else if filters.statuses.len() > 1 { + let placeholders: Vec<&str> = filters.statuses.iter().map(|_| "?").collect(); + status_in_clause = format!( + "i.status_name COLLATE NOCASE IN ({})", + placeholders.join(", ") + ); + where_clauses.push(&status_in_clause); + for s in filters.statuses { + params.push(Box::new(s.clone())); + } + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + let count_sql = format!( + "SELECT COUNT(*) FROM issues i + JOIN projects p ON i.project_id = p.id + {where_sql}" + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + let total_count = total_count as usize; + + let sort_column = match filters.sort { + "created" => "i.created_at", + "iid" => "i.iid", + _ => "i.updated_at", + }; + let order = if filters.order == "asc" { + "ASC" + } else { + "DESC" + }; + + let query_sql = format!( + "SELECT + i.iid, + i.title, + i.state, + i.author_username, + i.created_at, + i.updated_at, + i.web_url, + p.path_with_namespace, + (SELECT GROUP_CONCAT(l.name, X'1F') + FROM issue_labels il + JOIN labels l ON il.label_id = l.id + WHERE il.issue_id = i.id) AS labels_csv, + (SELECT GROUP_CONCAT(ia.username, X'1F') + FROM issue_assignees ia + WHERE ia.issue_id = i.id) AS assignees_csv, + (SELECT COUNT(*) FROM discussions d + 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, + i.status_name, + i.status_category, + i.status_color, + i.status_icon_name, + i.status_synced_at + FROM issues i + JOIN projects p ON i.project_id = p.id + {where_sql} + ORDER BY {sort_column} {order} + LIMIT ?" + ); + + params.push(Box::new(filters.limit as i64)); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&query_sql)?; + let issues: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + let labels_csv: Option = row.get(8)?; + let labels = labels_csv + .map(|s| s.split('\x1F').map(String::from).collect()) + .unwrap_or_default(); + + let assignees_csv: Option = row.get(9)?; + let assignees = assignees_csv + .map(|s| s.split('\x1F').map(String::from).collect()) + .unwrap_or_default(); + + Ok(IssueListRow { + iid: row.get(0)?, + title: row.get(1)?, + state: row.get(2)?, + author_username: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + web_url: row.get(6)?, + project_path: row.get(7)?, + labels, + assignees, + discussion_count: row.get(10)?, + unresolved_count: row.get(11)?, + status_name: row.get(12)?, + status_category: row.get(13)?, + status_color: row.get(14)?, + status_icon_name: row.get(15)?, + status_synced_at: row.get(16)?, + }) + })? + .collect::, _>>()?; + + Ok(ListResult { + issues, + total_count, + available_statuses: Vec::new(), + }) +} + +pub fn print_list_issues(result: &ListResult) { + if result.issues.is_empty() { + println!("No issues found."); + return; + } + + println!( + "{} {} of {}\n", + Theme::bold().render("Issues"), + result.issues.len(), + result.total_count + ); + + let has_any_status = result.issues.iter().any(|i| i.status_name.is_some()); + + let mut headers = vec!["IID", "Title", "State"]; + if has_any_status { + headers.push("Status"); + } + headers.extend(["Assignee", "Labels", "Disc", "Updated"]); + + let mut table = LoreTable::new().headers(&headers).align(0, Align::Right); + + for issue in &result.issues { + let title = render::truncate(&issue.title, 45); + let relative_time = render::format_relative_time_compact(issue.updated_at); + let labels = render::format_labels_bare(&issue.labels, 2); + let assignee = format_assignees(&issue.assignees); + let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); + + let (icon, state_style) = if issue.state == "opened" { + (Icons::issue_opened(), Theme::success()) + } else { + (Icons::issue_closed(), Theme::dim()) + }; + let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style); + + let mut row = vec![ + StyledCell::styled(format!("#{}", issue.iid), Theme::info()), + StyledCell::plain(title), + state_cell, + ]; + if has_any_status { + match &issue.status_name { + Some(status) => { + row.push(StyledCell::plain(render::style_with_hex( + status, + issue.status_color.as_deref(), + ))); + } + None => { + row.push(StyledCell::plain("")); + } + } + } + row.extend([ + StyledCell::styled(assignee, Theme::accent()), + StyledCell::styled(labels, Theme::warning()), + discussions, + StyledCell::styled(relative_time, Theme::dim()), + ]); + table.add_row(row); + } + + println!("{}", table.render()); +} + +pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { + let json_result = ListResultJson::from(result); + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": { + "elapsed_ms": elapsed_ms, + "available_statuses": result.available_statuses, + }, + }); + let mut output = output; + if let Some(f) = fields { + let expanded = expand_fields_preset(f, "issues"); + filter_fields(&mut output, "issues", &expanded); + } + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +pub fn open_issue_in_browser(result: &ListResult) -> Option { + let first_issue = result.issues.first()?; + let url = first_issue.web_url.as_ref()?; + + match open::that(url) { + Ok(()) => { + println!("Opened: {url}"); + Some(url.clone()) + } + Err(e) => { + eprintln!("Failed to open browser: {e}"); + None + } + } +} diff --git a/src/cli/commands/list_tests.rs b/src/cli/commands/list/list_tests.rs similarity index 96% rename from src/cli/commands/list_tests.rs rename to src/cli/commands/list/list_tests.rs index 3077344..1447321 100644 --- a/src/cli/commands/list_tests.rs +++ b/src/cli/commands/list/list_tests.rs @@ -1,6 +1,9 @@ use super::*; use crate::cli::render; use crate::core::time::now_ms; +use crate::test_support::{ + insert_project as insert_test_project, setup_test_db as setup_note_test_db, test_config, +}; #[test] fn truncate_leaves_short_strings_alone() { @@ -82,34 +85,6 @@ fn format_discussions_with_unresolved() { // Note query layer tests // ----------------------------------------------------------------------- -use std::path::Path; - -use crate::core::config::{ - Config, EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, - StorageConfig, SyncConfig, -}; -use crate::core::db::{create_connection, run_migrations}; - -fn test_config(default_project: Option<&str>) -> Config { - Config { - gitlab: GitLabConfig { - base_url: "https://gitlab.example.com".to_string(), - token_env_var: "GITLAB_TOKEN".to_string(), - token: None, - username: None, - }, - projects: vec![ProjectConfig { - path: "group/project".to_string(), - }], - default_project: default_project.map(String::from), - sync: SyncConfig::default(), - storage: StorageConfig::default(), - embedding: EmbeddingConfig::default(), - logging: LoggingConfig::default(), - scoring: ScoringConfig::default(), - } -} - fn default_note_filters() -> NoteListFilters { NoteListFilters { limit: 50, @@ -132,26 +107,6 @@ fn default_note_filters() -> NoteListFilters { } } -fn setup_note_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn -} - -fn insert_test_project(conn: &Connection, id: i64, path: &str) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) - VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![ - id, - id * 100, - path, - format!("https://gitlab.example.com/{path}") - ], - ) - .unwrap(); -} - fn insert_test_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, title: &str) { conn.execute( "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, diff --git a/src/cli/commands/list/mod.rs b/src/cli/commands/list/mod.rs new file mode 100644 index 0000000..ca5b67a --- /dev/null +++ b/src/cli/commands/list/mod.rs @@ -0,0 +1,28 @@ +mod issues; +mod mrs; +mod notes; +mod render_helpers; + +pub use issues::{ + IssueListRow, IssueListRowJson, ListFilters, ListResult, ListResultJson, open_issue_in_browser, + print_list_issues, print_list_issues_json, run_list_issues, +}; +pub use mrs::{ + MrListFilters, MrListResult, MrListResultJson, MrListRow, MrListRowJson, open_mr_in_browser, + print_list_mrs, print_list_mrs_json, run_list_mrs, +}; +pub use notes::{ + NoteListFilters, NoteListResult, NoteListResultJson, NoteListRow, NoteListRowJson, + print_list_notes, print_list_notes_json, query_notes, +}; + +#[cfg(test)] +use crate::core::path_resolver::escape_like as note_escape_like; +#[cfg(test)] +use render_helpers::{format_discussions, format_note_parent, format_note_type, truncate_body}; +#[cfg(test)] +use rusqlite::Connection; + +#[cfg(test)] +#[path = "list_tests.rs"] +mod tests; diff --git a/src/cli/commands/list/mrs.rs b/src/cli/commands/list/mrs.rs new file mode 100644 index 0000000..c13c16d --- /dev/null +++ b/src/cli/commands/list/mrs.rs @@ -0,0 +1,404 @@ +use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::Config; +use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields}; +use crate::core::db::create_connection; +use crate::core::error::{LoreError, Result}; +use crate::core::paths::get_db_path; +use crate::core::project::resolve_project; +use crate::core::time::{ms_to_iso, parse_since}; + +use super::render_helpers::{format_branches, format_discussions}; + +#[derive(Debug, Serialize)] +pub struct MrListRow { + pub iid: i64, + pub title: String, + pub state: String, + pub draft: bool, + pub author_username: String, + pub source_branch: String, + pub target_branch: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub assignees: Vec, + pub reviewers: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, +} + +#[derive(Serialize)] +pub struct MrListRowJson { + pub iid: i64, + pub title: String, + pub state: String, + pub draft: bool, + pub author_username: String, + pub source_branch: String, + pub target_branch: String, + pub labels: Vec, + pub assignees: Vec, + pub reviewers: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, + pub created_at_iso: String, + pub updated_at_iso: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, +} + +impl From<&MrListRow> for MrListRowJson { + fn from(row: &MrListRow) -> Self { + Self { + iid: row.iid, + title: row.title.clone(), + state: row.state.clone(), + draft: row.draft, + author_username: row.author_username.clone(), + source_branch: row.source_branch.clone(), + target_branch: row.target_branch.clone(), + labels: row.labels.clone(), + assignees: row.assignees.clone(), + reviewers: row.reviewers.clone(), + discussion_count: row.discussion_count, + unresolved_count: row.unresolved_count, + created_at_iso: ms_to_iso(row.created_at), + updated_at_iso: ms_to_iso(row.updated_at), + web_url: row.web_url.clone(), + project_path: row.project_path.clone(), + } + } +} + +#[derive(Serialize)] +pub struct MrListResult { + pub mrs: Vec, + pub total_count: usize, +} + +#[derive(Serialize)] +pub struct MrListResultJson { + pub mrs: Vec, + pub total_count: usize, + pub showing: usize, +} + +impl From<&MrListResult> for MrListResultJson { + fn from(result: &MrListResult) -> Self { + Self { + mrs: result.mrs.iter().map(MrListRowJson::from).collect(), + total_count: result.total_count, + showing: result.mrs.len(), + } + } +} + +pub struct MrListFilters<'a> { + pub limit: usize, + pub project: Option<&'a str>, + pub state: Option<&'a str>, + pub author: Option<&'a str>, + pub assignee: Option<&'a str>, + pub reviewer: Option<&'a str>, + pub labels: Option<&'a [String]>, + pub since: Option<&'a str>, + pub draft: bool, + pub no_draft: bool, + pub target_branch: Option<&'a str>, + pub source_branch: Option<&'a str>, + pub sort: &'a str, + pub order: &'a str, +} + +pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let result = query_mrs(&conn, &filters)?; + Ok(result) +} + +fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result { + let mut where_clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(project) = filters.project { + let project_id = resolve_project(conn, project)?; + where_clauses.push("m.project_id = ?"); + params.push(Box::new(project_id)); + } + + if let Some(state) = filters.state + && state != "all" + { + where_clauses.push("m.state = ?"); + params.push(Box::new(state.to_string())); + } + + if let Some(author) = filters.author { + let username = author.strip_prefix('@').unwrap_or(author); + where_clauses.push("m.author_username = ?"); + params.push(Box::new(username.to_string())); + } + + if let Some(assignee) = filters.assignee { + let username = assignee.strip_prefix('@').unwrap_or(assignee); + where_clauses.push( + "EXISTS (SELECT 1 FROM mr_assignees ma + WHERE ma.merge_request_id = m.id AND ma.username = ?)", + ); + params.push(Box::new(username.to_string())); + } + + if let Some(reviewer) = filters.reviewer { + let username = reviewer.strip_prefix('@').unwrap_or(reviewer); + where_clauses.push( + "EXISTS (SELECT 1 FROM mr_reviewers mr + WHERE mr.merge_request_id = m.id AND mr.username = ?)", + ); + params.push(Box::new(username.to_string())); + } + + if let Some(since_str) = filters.since { + let cutoff_ms = parse_since(since_str).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", + since_str + )) + })?; + where_clauses.push("m.updated_at >= ?"); + params.push(Box::new(cutoff_ms)); + } + + if let Some(labels) = filters.labels { + for label in labels { + where_clauses.push( + "EXISTS (SELECT 1 FROM mr_labels ml + JOIN labels l ON ml.label_id = l.id + WHERE ml.merge_request_id = m.id AND l.name = ?)", + ); + params.push(Box::new(label.clone())); + } + } + + if filters.draft { + where_clauses.push("m.draft = 1"); + } else if filters.no_draft { + where_clauses.push("m.draft = 0"); + } + + if let Some(target_branch) = filters.target_branch { + where_clauses.push("m.target_branch = ?"); + params.push(Box::new(target_branch.to_string())); + } + + if let Some(source_branch) = filters.source_branch { + where_clauses.push("m.source_branch = ?"); + params.push(Box::new(source_branch.to_string())); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + let count_sql = format!( + "SELECT COUNT(*) FROM merge_requests m + JOIN projects p ON m.project_id = p.id + {where_sql}" + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + let total_count = total_count as usize; + + let sort_column = match filters.sort { + "created" => "m.created_at", + "iid" => "m.iid", + _ => "m.updated_at", + }; + let order = if filters.order == "asc" { + "ASC" + } else { + "DESC" + }; + + let query_sql = format!( + "SELECT + m.iid, + m.title, + m.state, + m.draft, + m.author_username, + m.source_branch, + m.target_branch, + m.created_at, + m.updated_at, + m.web_url, + p.path_with_namespace, + (SELECT GROUP_CONCAT(l.name, X'1F') + FROM mr_labels ml + JOIN labels l ON ml.label_id = l.id + WHERE ml.merge_request_id = m.id) AS labels_csv, + (SELECT GROUP_CONCAT(ma.username, X'1F') + FROM mr_assignees ma + WHERE ma.merge_request_id = m.id) AS assignees_csv, + (SELECT GROUP_CONCAT(mr.username, X'1F') + FROM mr_reviewers mr + WHERE mr.merge_request_id = m.id) AS reviewers_csv, + (SELECT COUNT(*) FROM discussions d + 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 + JOIN projects p ON m.project_id = p.id + {where_sql} + ORDER BY {sort_column} {order} + LIMIT ?" + ); + + params.push(Box::new(filters.limit as i64)); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&query_sql)?; + let mrs: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + let labels_csv: Option = row.get(11)?; + let labels = labels_csv + .map(|s| s.split('\x1F').map(String::from).collect()) + .unwrap_or_default(); + + let assignees_csv: Option = row.get(12)?; + let assignees = assignees_csv + .map(|s| s.split('\x1F').map(String::from).collect()) + .unwrap_or_default(); + + let reviewers_csv: Option = row.get(13)?; + let reviewers = reviewers_csv + .map(|s| s.split('\x1F').map(String::from).collect()) + .unwrap_or_default(); + + let draft_int: i64 = row.get(3)?; + + Ok(MrListRow { + iid: row.get(0)?, + title: row.get(1)?, + state: row.get(2)?, + draft: draft_int == 1, + author_username: row.get(4)?, + source_branch: row.get(5)?, + target_branch: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + web_url: row.get(9)?, + project_path: row.get(10)?, + labels, + assignees, + reviewers, + discussion_count: row.get(14)?, + unresolved_count: row.get(15)?, + }) + })? + .collect::, _>>()?; + + Ok(MrListResult { mrs, total_count }) +} + +pub fn print_list_mrs(result: &MrListResult) { + if result.mrs.is_empty() { + println!("No merge requests found."); + return; + } + + println!( + "{} {} of {}\n", + Theme::bold().render("Merge Requests"), + result.mrs.len(), + result.total_count + ); + + let mut table = LoreTable::new() + .headers(&[ + "IID", "Title", "State", "Author", "Branches", "Disc", "Updated", + ]) + .align(0, Align::Right); + + for mr in &result.mrs { + let title = if mr.draft { + format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) + } else { + render::truncate(&mr.title, 45) + }; + + let relative_time = render::format_relative_time_compact(mr.updated_at); + let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); + let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); + + let (icon, style) = match mr.state.as_str() { + "opened" => (Icons::mr_opened(), Theme::success()), + "merged" => (Icons::mr_merged(), Theme::accent()), + "closed" => (Icons::mr_closed(), Theme::error()), + "locked" => (Icons::mr_opened(), Theme::warning()), + _ => (Icons::mr_opened(), Theme::dim()), + }; + let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style); + + table.add_row(vec![ + StyledCell::styled(format!("!{}", mr.iid), Theme::info()), + StyledCell::plain(title), + state_cell, + StyledCell::styled( + format!("@{}", render::truncate(&mr.author_username, 12)), + Theme::accent(), + ), + StyledCell::styled(branches, Theme::info()), + discussions, + StyledCell::styled(relative_time, Theme::dim()), + ]); + } + + println!("{}", table.render()); +} + +pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) { + let json_result = MrListResultJson::from(result); + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + let mut output = output; + if let Some(f) = fields { + let expanded = expand_fields_preset(f, "mrs"); + filter_fields(&mut output, "mrs", &expanded); + } + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +pub fn open_mr_in_browser(result: &MrListResult) -> Option { + let first_mr = result.mrs.first()?; + let url = first_mr.web_url.as_ref()?; + + match open::that(url) { + Ok(()) => { + println!("Opened: {url}"); + Some(url.clone()) + } + Err(e) => { + eprintln!("Failed to open browser: {e}"); + None + } + } +} diff --git a/src/cli/commands/list/notes.rs b/src/cli/commands/list/notes.rs new file mode 100644 index 0000000..2b43574 --- /dev/null +++ b/src/cli/commands/list/notes.rs @@ -0,0 +1,470 @@ +use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::Config; +use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields}; +use crate::core::error::{LoreError, Result}; +use crate::core::path_resolver::escape_like as note_escape_like; +use crate::core::project::resolve_project; +use crate::core::time::{iso_to_ms, ms_to_iso, parse_since}; + +use super::render_helpers::{ + format_note_parent, format_note_path, format_note_type, truncate_body, +}; + +#[derive(Debug, Serialize)] +pub struct NoteListRow { + pub id: i64, + pub gitlab_id: i64, + pub author_username: String, + pub body: Option, + pub note_type: Option, + pub is_system: bool, + pub created_at: i64, + pub updated_at: i64, + pub position_new_path: Option, + pub position_new_line: Option, + pub position_old_path: Option, + pub position_old_line: Option, + pub resolvable: bool, + pub resolved: bool, + pub resolved_by: Option, + pub noteable_type: Option, + pub parent_iid: Option, + pub parent_title: Option, + pub project_path: String, +} + +#[derive(Serialize)] +pub struct NoteListRowJson { + pub id: i64, + pub gitlab_id: i64, + pub author_username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub note_type: Option, + pub is_system: bool, + pub created_at_iso: String, + pub updated_at_iso: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub position_new_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position_new_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position_old_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position_old_line: Option, + pub resolvable: bool, + pub resolved: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub noteable_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_iid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_title: Option, + pub project_path: String, +} + +impl From<&NoteListRow> for NoteListRowJson { + fn from(row: &NoteListRow) -> Self { + Self { + id: row.id, + gitlab_id: row.gitlab_id, + author_username: row.author_username.clone(), + body: row.body.clone(), + note_type: row.note_type.clone(), + is_system: row.is_system, + created_at_iso: ms_to_iso(row.created_at), + updated_at_iso: ms_to_iso(row.updated_at), + position_new_path: row.position_new_path.clone(), + position_new_line: row.position_new_line, + position_old_path: row.position_old_path.clone(), + position_old_line: row.position_old_line, + resolvable: row.resolvable, + resolved: row.resolved, + resolved_by: row.resolved_by.clone(), + noteable_type: row.noteable_type.clone(), + parent_iid: row.parent_iid, + parent_title: row.parent_title.clone(), + project_path: row.project_path.clone(), + } + } +} + +#[derive(Debug)] +pub struct NoteListResult { + pub notes: Vec, + pub total_count: i64, +} + +#[derive(Serialize)] +pub struct NoteListResultJson { + pub notes: Vec, + pub total_count: i64, + pub showing: usize, +} + +impl From<&NoteListResult> for NoteListResultJson { + fn from(result: &NoteListResult) -> Self { + Self { + notes: result.notes.iter().map(NoteListRowJson::from).collect(), + total_count: result.total_count, + showing: result.notes.len(), + } + } +} + +pub struct NoteListFilters { + pub limit: usize, + pub project: Option, + pub author: Option, + pub note_type: Option, + pub include_system: bool, + pub for_issue_iid: Option, + pub for_mr_iid: Option, + pub note_id: Option, + pub gitlab_note_id: Option, + pub discussion_id: Option, + pub since: Option, + pub until: Option, + pub path: Option, + pub contains: Option, + pub resolution: Option, + pub sort: String, + pub order: String, +} + +pub fn print_list_notes(result: &NoteListResult) { + if result.notes.is_empty() { + println!("No notes found."); + return; + } + + println!( + "{} {} of {}\n", + Theme::bold().render("Notes"), + result.notes.len(), + result.total_count + ); + + let mut table = LoreTable::new() + .headers(&[ + "ID", + "Author", + "Type", + "Body", + "Path:Line", + "Parent", + "Created", + ]) + .align(0, Align::Right); + + for note in &result.notes { + let body = note + .body + .as_deref() + .map(|b| truncate_body(b, 60)) + .unwrap_or_default(); + let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); + let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid); + let relative_time = render::format_relative_time_compact(note.created_at); + let note_type = format_note_type(note.note_type.as_deref()); + + table.add_row(vec![ + StyledCell::styled(note.gitlab_id.to_string(), Theme::info()), + StyledCell::styled( + format!("@{}", render::truncate(¬e.author_username, 12)), + Theme::accent(), + ), + StyledCell::plain(note_type), + StyledCell::plain(body), + StyledCell::plain(path), + StyledCell::plain(parent), + StyledCell::styled(relative_time, Theme::dim()), + ]); + } + + println!("{}", table.render()); +} + +pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) { + let json_result = NoteListResultJson::from(result); + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + let mut output = output; + if let Some(f) = fields { + let expanded = expand_fields_preset(f, "notes"); + filter_fields(&mut output, "notes", &expanded); + } + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +pub fn query_notes( + conn: &Connection, + filters: &NoteListFilters, + config: &Config, +) -> Result { + let mut where_clauses: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref project) = filters.project { + let project_id = resolve_project(conn, project)?; + where_clauses.push("n.project_id = ?".to_string()); + params.push(Box::new(project_id)); + } + + if let Some(ref author) = filters.author { + let username = author.strip_prefix('@').unwrap_or(author); + where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string()); + params.push(Box::new(username.to_string())); + } + + if let Some(ref note_type) = filters.note_type { + where_clauses.push("n.note_type = ?".to_string()); + params.push(Box::new(note_type.clone())); + } + + if !filters.include_system { + where_clauses.push("n.is_system = 0".to_string()); + } + + let since_ms = if let Some(ref since_str) = filters.since { + let ms = parse_since(since_str).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", + since_str + )) + })?; + where_clauses.push("n.created_at >= ?".to_string()); + params.push(Box::new(ms)); + Some(ms) + } else { + None + }; + + if let Some(ref until_str) = filters.until { + let until_ms = if until_str.len() == 10 + && until_str.chars().filter(|&c| c == '-').count() == 2 + { + let iso_full = format!("{until_str}T23:59:59.999Z"); + iso_to_ms(&iso_full).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --until value '{}'. Use YYYY-MM-DD or relative format.", + until_str + )) + })? + } else { + parse_since(until_str).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", + until_str + )) + })? + }; + + if let Some(s) = since_ms + && s > until_ms + { + return Err(LoreError::Other( + "Invalid time window: --since is after --until.".to_string(), + )); + } + + where_clauses.push("n.created_at <= ?".to_string()); + params.push(Box::new(until_ms)); + } + + if let Some(ref path) = filters.path { + if let Some(prefix) = path.strip_suffix('/') { + let escaped = note_escape_like(prefix); + where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string()); + params.push(Box::new(format!("{escaped}%"))); + } else { + where_clauses.push("n.position_new_path = ?".to_string()); + params.push(Box::new(path.clone())); + } + } + + if let Some(ref contains) = filters.contains { + let escaped = note_escape_like(contains); + where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string()); + params.push(Box::new(format!("%{escaped}%"))); + } + + if let Some(ref resolution) = filters.resolution { + match resolution.as_str() { + "unresolved" => { + where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string()); + } + "resolved" => { + where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string()); + } + other => { + return Err(LoreError::Other(format!( + "Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.", + other + ))); + } + } + } + + if let Some(iid) = filters.for_issue_iid { + let project_str = filters + .project + .as_deref() + .or(config.default_project.as_deref()) + .ok_or_else(|| { + LoreError::Other( + "Cannot filter by issue IID without a project context. Use --project or set defaultProject in config." + .to_string(), + ) + })?; + let project_id = resolve_project(conn, project_str)?; + where_clauses.push( + "d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(), + ); + params.push(Box::new(project_id)); + params.push(Box::new(iid)); + } + + if let Some(iid) = filters.for_mr_iid { + let project_str = filters + .project + .as_deref() + .or(config.default_project.as_deref()) + .ok_or_else(|| { + LoreError::Other( + "Cannot filter by MR IID without a project context. Use --project or set defaultProject in config." + .to_string(), + ) + })?; + let project_id = resolve_project(conn, project_str)?; + where_clauses.push( + "d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)" + .to_string(), + ); + params.push(Box::new(project_id)); + params.push(Box::new(iid)); + } + + if let Some(id) = filters.note_id { + where_clauses.push("n.id = ?".to_string()); + params.push(Box::new(id)); + } + + if let Some(gitlab_id) = filters.gitlab_note_id { + where_clauses.push("n.gitlab_id = ?".to_string()); + params.push(Box::new(gitlab_id)); + } + + if let Some(ref disc_id) = filters.discussion_id { + where_clauses.push("d.gitlab_discussion_id = ?".to_string()); + params.push(Box::new(disc_id.clone())); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + let count_sql = format!( + "SELECT COUNT(*) FROM notes n + JOIN discussions d ON n.discussion_id = d.id + JOIN projects p ON n.project_id = p.id + LEFT JOIN issues i ON d.issue_id = i.id + LEFT JOIN merge_requests m ON d.merge_request_id = m.id + {where_sql}" + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + + let sort_column = match filters.sort.as_str() { + "updated" => "n.updated_at", + _ => "n.created_at", + }; + let order = if filters.order == "asc" { + "ASC" + } else { + "DESC" + }; + + let query_sql = format!( + "SELECT + n.id, + n.gitlab_id, + n.author_username, + n.body, + n.note_type, + n.is_system, + n.created_at, + n.updated_at, + n.position_new_path, + n.position_new_line, + n.position_old_path, + n.position_old_line, + n.resolvable, + n.resolved, + n.resolved_by, + d.noteable_type, + COALESCE(i.iid, m.iid) AS parent_iid, + COALESCE(i.title, m.title) AS parent_title, + p.path_with_namespace AS project_path + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + JOIN projects p ON n.project_id = p.id + LEFT JOIN issues i ON d.issue_id = i.id + LEFT JOIN merge_requests m ON d.merge_request_id = m.id + {where_sql} + ORDER BY {sort_column} {order}, n.id {order} + LIMIT ?" + ); + + params.push(Box::new(filters.limit as i64)); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&query_sql)?; + let notes: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + let is_system_int: i64 = row.get(5)?; + let resolvable_int: i64 = row.get(12)?; + let resolved_int: i64 = row.get(13)?; + + Ok(NoteListRow { + id: row.get(0)?, + gitlab_id: row.get(1)?, + author_username: row.get::<_, Option>(2)?.unwrap_or_default(), + body: row.get(3)?, + note_type: row.get(4)?, + is_system: is_system_int == 1, + created_at: row.get(6)?, + updated_at: row.get(7)?, + position_new_path: row.get(8)?, + position_new_line: row.get(9)?, + position_old_path: row.get(10)?, + position_old_line: row.get(11)?, + resolvable: resolvable_int == 1, + resolved: resolved_int == 1, + resolved_by: row.get(14)?, + noteable_type: row.get(15)?, + parent_iid: row.get(16)?, + parent_title: row.get(17)?, + project_path: row.get(18)?, + }) + })? + .collect::, _>>()?; + + Ok(NoteListResult { notes, total_count }) +} diff --git a/src/cli/commands/list/render_helpers.rs b/src/cli/commands/list/render_helpers.rs new file mode 100644 index 0000000..e0449e1 --- /dev/null +++ b/src/cli/commands/list/render_helpers.rs @@ -0,0 +1,73 @@ +use crate::cli::render::{self, StyledCell, Theme}; + +pub(crate) fn format_assignees(assignees: &[String]) -> String { + if assignees.is_empty() { + return "-".to_string(); + } + + let max_shown = 2; + let shown: Vec = assignees + .iter() + .take(max_shown) + .map(|s| format!("@{}", render::truncate(s, 10))) + .collect(); + let overflow = assignees.len().saturating_sub(max_shown); + + if overflow > 0 { + format!("{} +{}", shown.join(", "), overflow) + } else { + shown.join(", ") + } +} + +pub(crate) fn format_discussions(total: i64, unresolved: i64) -> StyledCell { + if total == 0 { + return StyledCell::plain(String::new()); + } + + if unresolved > 0 { + let text = format!("{total}/"); + let warn = Theme::warning().render(&format!("{unresolved}!")); + StyledCell::plain(format!("{text}{warn}")) + } else { + StyledCell::plain(format!("{total}")) + } +} + +pub(crate) fn format_branches(target: &str, source: &str, max_width: usize) -> String { + let full = format!("{} <- {}", target, source); + render::truncate(&full, max_width) +} + +pub(crate) fn truncate_body(body: &str, max_len: usize) -> String { + if body.chars().count() <= max_len { + body.to_string() + } else { + let truncated: String = body.chars().take(max_len).collect(); + format!("{truncated}...") + } +} + +pub(crate) fn format_note_type(note_type: Option<&str>) -> &'static str { + match note_type { + Some("DiffNote") => "Diff", + Some("DiscussionNote") => "Disc", + _ => "-", + } +} + +pub(crate) fn format_note_path(path: Option<&str>, line: Option) -> String { + match (path, line) { + (Some(p), Some(l)) => format!("{p}:{l}"), + (Some(p), None) => p.to_string(), + _ => "-".to_string(), + } +} + +pub(crate) fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option) -> String { + match (noteable_type, parent_iid) { + (Some("Issue"), Some(iid)) => format!("Issue #{iid}"), + (Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"), + _ => "-".to_string(), + } +} diff --git a/src/cli/commands/me/me_tests.rs b/src/cli/commands/me/me_tests.rs index 5750717..e2b9b5d 100644 --- a/src/cli/commands/me/me_tests.rs +++ b/src/cli/commands/me/me_tests.rs @@ -1,32 +1,11 @@ use super::*; use crate::cli::commands::me::types::{ActivityEventType, AttentionState}; -use crate::core::db::{create_connection, run_migrations}; use crate::core::time::now_ms; +use crate::test_support::{insert_project, setup_test_db}; use rusqlite::Connection; -use std::path::Path; // ─── Helpers ──────────────────────────────────────────────────────────────── -fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn -} - -fn insert_project(conn: &Connection, id: i64, path: &str) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) - VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![ - id, - id * 100, - path, - format!("https://git.example.com/{path}") - ], - ) - .unwrap(); -} - fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) { insert_issue_with_status( conn, diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index d22f236..ed3ba81 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -17,7 +17,6 @@ pub mod show; pub mod stats; pub mod sync; pub mod sync_status; -pub mod sync_surgical; pub mod timeline; pub mod trace; pub mod who; @@ -61,9 +60,8 @@ pub use show::{ run_show_mr, }; pub use stats::{print_stats, print_stats_json, run_stats}; -pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync}; +pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync, run_sync_surgical}; pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status}; -pub use sync_surgical::run_sync_surgical; pub use timeline::{TimelineParams, print_timeline, print_timeline_json_with_meta, run_timeline}; pub use trace::{parse_trace_path, print_trace, print_trace_json}; pub use who::{WhoRun, print_who_human, print_who_json, run_who}; diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs deleted file mode 100644 index 41de18d..0000000 --- a/src/cli/commands/show.rs +++ /dev/null @@ -1,1544 +0,0 @@ -use crate::cli::render::{self, Icons, Theme}; -use rusqlite::Connection; -use serde::Serialize; - -use crate::Config; -use crate::cli::robot::RobotMeta; -use crate::core::db::create_connection; -use crate::core::error::{LoreError, Result}; -use crate::core::paths::get_db_path; -use crate::core::project::resolve_project; -use crate::core::time::ms_to_iso; - -#[derive(Debug, Serialize)] -pub struct MrDetail { - pub id: i64, - pub iid: i64, - pub title: String, - pub description: Option, - pub state: String, - pub draft: bool, - pub author_username: String, - pub source_branch: String, - pub target_branch: String, - pub created_at: i64, - pub updated_at: i64, - pub merged_at: Option, - pub closed_at: Option, - pub web_url: Option, - pub project_path: String, - pub labels: Vec, - pub assignees: Vec, - pub reviewers: Vec, - pub discussions: Vec, -} - -#[derive(Debug, Serialize)] -pub struct MrDiscussionDetail { - pub notes: Vec, - pub individual_note: bool, -} - -#[derive(Debug, Serialize)] -pub struct MrNoteDetail { - pub author_username: String, - pub body: String, - pub created_at: i64, - pub is_system: bool, - pub position: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DiffNotePosition { - pub old_path: Option, - pub new_path: Option, - pub old_line: Option, - pub new_line: Option, - pub position_type: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ClosingMrRef { - pub iid: i64, - pub title: String, - pub state: String, - pub web_url: Option, -} - -#[derive(Debug, Serialize)] -pub struct IssueDetail { - pub id: i64, - pub iid: i64, - pub title: String, - pub description: Option, - pub state: String, - pub author_username: String, - pub created_at: i64, - pub updated_at: i64, - pub closed_at: Option, - pub confidential: bool, - pub web_url: Option, - pub project_path: String, - pub references_full: String, - pub labels: Vec, - pub assignees: Vec, - pub due_date: Option, - pub milestone: Option, - pub user_notes_count: i64, - pub merge_requests_count: usize, - pub closing_merge_requests: Vec, - pub discussions: Vec, - pub status_name: Option, - pub status_category: Option, - pub status_color: Option, - pub status_icon_name: Option, - pub status_synced_at: Option, -} - -#[derive(Debug, Serialize)] -pub struct DiscussionDetail { - pub notes: Vec, - pub individual_note: bool, -} - -#[derive(Debug, Serialize)] -pub struct NoteDetail { - pub author_username: String, - pub body: String, - pub created_at: i64, - pub is_system: bool, -} - -pub fn run_show_issue( - config: &Config, - iid: i64, - project_filter: Option<&str>, -) -> Result { - let db_path = get_db_path(config.storage.db_path.as_deref()); - let conn = create_connection(&db_path)?; - - let issue = find_issue(&conn, iid, project_filter)?; - - let labels = get_issue_labels(&conn, issue.id)?; - - let assignees = get_issue_assignees(&conn, issue.id)?; - - let closing_mrs = get_closing_mrs(&conn, issue.id)?; - - let discussions = get_issue_discussions(&conn, issue.id)?; - - let references_full = format!("{}#{}", issue.project_path, issue.iid); - let merge_requests_count = closing_mrs.len(); - - Ok(IssueDetail { - id: issue.id, - iid: issue.iid, - title: issue.title, - description: issue.description, - state: issue.state, - author_username: issue.author_username, - created_at: issue.created_at, - updated_at: issue.updated_at, - closed_at: issue.closed_at, - confidential: issue.confidential, - web_url: issue.web_url, - project_path: issue.project_path, - references_full, - labels, - assignees, - due_date: issue.due_date, - milestone: issue.milestone_title, - user_notes_count: issue.user_notes_count, - merge_requests_count, - closing_merge_requests: closing_mrs, - discussions, - status_name: issue.status_name, - status_category: issue.status_category, - status_color: issue.status_color, - status_icon_name: issue.status_icon_name, - status_synced_at: issue.status_synced_at, - }) -} - -#[derive(Debug)] -struct IssueRow { - id: i64, - iid: i64, - title: String, - description: Option, - state: String, - author_username: String, - created_at: i64, - updated_at: i64, - closed_at: Option, - confidential: bool, - web_url: Option, - project_path: String, - due_date: Option, - milestone_title: Option, - user_notes_count: i64, - status_name: Option, - status_category: Option, - status_color: Option, - status_icon_name: Option, - status_synced_at: Option, -} - -fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { - let (sql, params): (&str, Vec>) = match project_filter { - Some(project) => { - let project_id = resolve_project(conn, project)?; - ( - "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, - i.created_at, i.updated_at, i.closed_at, i.confidential, - i.web_url, p.path_with_namespace, - i.due_date, i.milestone_title, - (SELECT COUNT(*) FROM notes n - JOIN discussions d ON n.discussion_id = d.id - WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count, - i.status_name, i.status_category, i.status_color, - i.status_icon_name, i.status_synced_at - FROM issues i - JOIN projects p ON i.project_id = p.id - WHERE i.iid = ? AND i.project_id = ?", - vec![Box::new(iid), Box::new(project_id)], - ) - } - None => ( - "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, - i.created_at, i.updated_at, i.closed_at, i.confidential, - i.web_url, p.path_with_namespace, - i.due_date, i.milestone_title, - (SELECT COUNT(*) FROM notes n - JOIN discussions d ON n.discussion_id = d.id - WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count, - i.status_name, i.status_category, i.status_color, - i.status_icon_name, i.status_synced_at - FROM issues i - JOIN projects p ON i.project_id = p.id - WHERE i.iid = ?", - vec![Box::new(iid)], - ), - }; - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let mut stmt = conn.prepare(sql)?; - let issues: Vec = stmt - .query_map(param_refs.as_slice(), |row| { - let confidential_val: i64 = row.get(9)?; - Ok(IssueRow { - id: row.get(0)?, - iid: row.get(1)?, - title: row.get(2)?, - description: row.get(3)?, - state: row.get(4)?, - author_username: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - closed_at: row.get(8)?, - confidential: confidential_val != 0, - web_url: row.get(10)?, - project_path: row.get(11)?, - due_date: row.get(12)?, - milestone_title: row.get(13)?, - user_notes_count: row.get(14)?, - status_name: row.get(15)?, - status_category: row.get(16)?, - status_color: row.get(17)?, - status_icon_name: row.get(18)?, - status_synced_at: row.get(19)?, - }) - })? - .collect::, _>>()?; - - match issues.len() { - 0 => Err(LoreError::NotFound(format!("Issue #{} not found", iid))), - 1 => Ok(issues.into_iter().next().unwrap()), - _ => { - let projects: Vec = issues.iter().map(|i| i.project_path.clone()).collect(); - Err(LoreError::Ambiguous(format!( - "Issue #{} exists in multiple projects: {}. Use --project to specify.", - iid, - projects.join(", ") - ))) - } - } -} - -fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT l.name FROM labels l - JOIN issue_labels il ON l.id = il.label_id - WHERE il.issue_id = ? - ORDER BY l.name", - )?; - - let labels: Vec = stmt - .query_map([issue_id], |row| row.get(0))? - .collect::, _>>()?; - - Ok(labels) -} - -fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT username FROM issue_assignees - WHERE issue_id = ? - ORDER BY username", - )?; - - let assignees: Vec = stmt - .query_map([issue_id], |row| row.get(0))? - .collect::, _>>()?; - - Ok(assignees) -} - -fn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT mr.iid, mr.title, mr.state, mr.web_url - FROM entity_references er - JOIN merge_requests mr ON mr.id = er.source_entity_id - WHERE er.target_entity_type = 'issue' - AND er.target_entity_id = ? - AND er.source_entity_type = 'merge_request' - AND er.reference_type = 'closes' - ORDER BY mr.iid", - )?; - - let mrs: Vec = stmt - .query_map([issue_id], |row| { - Ok(ClosingMrRef { - iid: row.get(0)?, - title: row.get(1)?, - state: row.get(2)?, - web_url: row.get(3)?, - }) - })? - .collect::, _>>()?; - - Ok(mrs) -} - -fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { - let mut disc_stmt = conn.prepare( - "SELECT id, individual_note FROM discussions - WHERE issue_id = ? - ORDER BY first_note_at", - )?; - - let disc_rows: Vec<(i64, bool)> = disc_stmt - .query_map([issue_id], |row| { - let individual: i64 = row.get(1)?; - Ok((row.get(0)?, individual == 1)) - })? - .collect::, _>>()?; - - let mut note_stmt = conn.prepare( - "SELECT author_username, body, created_at, is_system - FROM notes - WHERE discussion_id = ? - ORDER BY position", - )?; - - let mut discussions = Vec::new(); - for (disc_id, individual_note) in disc_rows { - let notes: Vec = note_stmt - .query_map([disc_id], |row| { - let is_system: i64 = row.get(3)?; - Ok(NoteDetail { - author_username: row.get(0)?, - body: row.get(1)?, - created_at: row.get(2)?, - is_system: is_system == 1, - }) - })? - .collect::, _>>()?; - - let has_user_notes = notes.iter().any(|n| !n.is_system); - if has_user_notes || notes.is_empty() { - discussions.push(DiscussionDetail { - notes, - individual_note, - }); - } - } - - Ok(discussions) -} - -pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result { - let db_path = get_db_path(config.storage.db_path.as_deref()); - let conn = create_connection(&db_path)?; - - let mr = find_mr(&conn, iid, project_filter)?; - - let labels = get_mr_labels(&conn, mr.id)?; - - let assignees = get_mr_assignees(&conn, mr.id)?; - - let reviewers = get_mr_reviewers(&conn, mr.id)?; - - let discussions = get_mr_discussions(&conn, mr.id)?; - - Ok(MrDetail { - id: mr.id, - iid: mr.iid, - title: mr.title, - description: mr.description, - state: mr.state, - draft: mr.draft, - author_username: mr.author_username, - source_branch: mr.source_branch, - target_branch: mr.target_branch, - created_at: mr.created_at, - updated_at: mr.updated_at, - merged_at: mr.merged_at, - closed_at: mr.closed_at, - web_url: mr.web_url, - project_path: mr.project_path, - labels, - assignees, - reviewers, - discussions, - }) -} - -struct MrRow { - id: i64, - iid: i64, - title: String, - description: Option, - state: String, - draft: bool, - author_username: String, - source_branch: String, - target_branch: String, - created_at: i64, - updated_at: i64, - merged_at: Option, - closed_at: Option, - web_url: Option, - project_path: String, -} - -fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { - let (sql, params): (&str, Vec>) = match project_filter { - Some(project) => { - let project_id = resolve_project(conn, project)?; - ( - "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, - m.author_username, m.source_branch, m.target_branch, - m.created_at, m.updated_at, m.merged_at, m.closed_at, - m.web_url, p.path_with_namespace - FROM merge_requests m - JOIN projects p ON m.project_id = p.id - WHERE m.iid = ? AND m.project_id = ?", - vec![Box::new(iid), Box::new(project_id)], - ) - } - None => ( - "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, - m.author_username, m.source_branch, m.target_branch, - m.created_at, m.updated_at, m.merged_at, m.closed_at, - m.web_url, p.path_with_namespace - FROM merge_requests m - JOIN projects p ON m.project_id = p.id - WHERE m.iid = ?", - vec![Box::new(iid)], - ), - }; - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let mut stmt = conn.prepare(sql)?; - let mrs: Vec = stmt - .query_map(param_refs.as_slice(), |row| { - let draft_val: i64 = row.get(5)?; - Ok(MrRow { - id: row.get(0)?, - iid: row.get(1)?, - title: row.get(2)?, - description: row.get(3)?, - state: row.get(4)?, - draft: draft_val == 1, - author_username: row.get(6)?, - source_branch: row.get(7)?, - target_branch: row.get(8)?, - created_at: row.get(9)?, - updated_at: row.get(10)?, - merged_at: row.get(11)?, - closed_at: row.get(12)?, - web_url: row.get(13)?, - project_path: row.get(14)?, - }) - })? - .collect::, _>>()?; - - match mrs.len() { - 0 => Err(LoreError::NotFound(format!("MR !{} not found", iid))), - 1 => Ok(mrs.into_iter().next().unwrap()), - _ => { - let projects: Vec = mrs.iter().map(|m| m.project_path.clone()).collect(); - Err(LoreError::Ambiguous(format!( - "MR !{} exists in multiple projects: {}. Use --project to specify.", - iid, - projects.join(", ") - ))) - } - } -} - -fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT l.name FROM labels l - JOIN mr_labels ml ON l.id = ml.label_id - WHERE ml.merge_request_id = ? - ORDER BY l.name", - )?; - - let labels: Vec = stmt - .query_map([mr_id], |row| row.get(0))? - .collect::, _>>()?; - - Ok(labels) -} - -fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT username FROM mr_assignees - WHERE merge_request_id = ? - ORDER BY username", - )?; - - let assignees: Vec = stmt - .query_map([mr_id], |row| row.get(0))? - .collect::, _>>()?; - - Ok(assignees) -} - -fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT username FROM mr_reviewers - WHERE merge_request_id = ? - ORDER BY username", - )?; - - let reviewers: Vec = stmt - .query_map([mr_id], |row| row.get(0))? - .collect::, _>>()?; - - Ok(reviewers) -} - -fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result> { - let mut disc_stmt = conn.prepare( - "SELECT id, individual_note FROM discussions - WHERE merge_request_id = ? - ORDER BY first_note_at", - )?; - - let disc_rows: Vec<(i64, bool)> = disc_stmt - .query_map([mr_id], |row| { - let individual: i64 = row.get(1)?; - Ok((row.get(0)?, individual == 1)) - })? - .collect::, _>>()?; - - let mut note_stmt = conn.prepare( - "SELECT author_username, body, created_at, is_system, - position_old_path, position_new_path, position_old_line, - position_new_line, position_type - FROM notes - WHERE discussion_id = ? - ORDER BY position", - )?; - - let mut discussions = Vec::new(); - for (disc_id, individual_note) in disc_rows { - let notes: Vec = note_stmt - .query_map([disc_id], |row| { - let is_system: i64 = row.get(3)?; - let old_path: Option = row.get(4)?; - let new_path: Option = row.get(5)?; - let old_line: Option = row.get(6)?; - let new_line: Option = row.get(7)?; - let position_type: Option = row.get(8)?; - - let position = if old_path.is_some() - || new_path.is_some() - || old_line.is_some() - || new_line.is_some() - { - Some(DiffNotePosition { - old_path, - new_path, - old_line, - new_line, - position_type, - }) - } else { - None - }; - - Ok(MrNoteDetail { - author_username: row.get(0)?, - body: row.get(1)?, - created_at: row.get(2)?, - is_system: is_system == 1, - position, - }) - })? - .collect::, _>>()?; - - let has_user_notes = notes.iter().any(|n| !n.is_system); - if has_user_notes || notes.is_empty() { - discussions.push(MrDiscussionDetail { - notes, - individual_note, - }); - } - } - - Ok(discussions) -} - -fn format_date(ms: i64) -> String { - render::format_date(ms) -} - -fn wrap_text(text: &str, width: usize, indent: &str) -> String { - render::wrap_indent(text, width, indent) -} - -pub fn print_show_issue(issue: &IssueDetail) { - // Title line - println!( - " Issue #{}: {}", - issue.iid, - Theme::bold().render(&issue.title), - ); - - // Details section - println!("{}", render::section_divider("Details")); - - println!( - " Ref {}", - Theme::muted().render(&issue.references_full) - ); - println!( - " Project {}", - Theme::info().render(&issue.project_path) - ); - - let (icon, state_style) = if issue.state == "opened" { - (Icons::issue_opened(), Theme::success()) - } else { - (Icons::issue_closed(), Theme::dim()) - }; - println!( - " State {}", - state_style.render(&format!("{icon} {}", issue.state)) - ); - - if let Some(status) = &issue.status_name { - println!( - " Status {}", - render::style_with_hex(status, issue.status_color.as_deref()) - ); - } - - if issue.confidential { - println!(" {}", Theme::error().bold().render("CONFIDENTIAL")); - } - - println!(" Author @{}", issue.author_username); - - if !issue.assignees.is_empty() { - let label = if issue.assignees.len() > 1 { - "Assignees" - } else { - "Assignee" - }; - println!( - " {}{} {}", - label, - " ".repeat(12 - label.len()), - issue - .assignees - .iter() - .map(|a| format!("@{a}")) - .collect::>() - .join(", ") - ); - } - - println!( - " Created {} ({})", - format_date(issue.created_at), - render::format_relative_time_compact(issue.created_at), - ); - println!( - " Updated {} ({})", - format_date(issue.updated_at), - render::format_relative_time_compact(issue.updated_at), - ); - - if let Some(closed_at) = &issue.closed_at { - println!(" Closed {closed_at}"); - } - - if let Some(due) = &issue.due_date { - println!(" Due {due}"); - } - - if let Some(ms) = &issue.milestone { - println!(" Milestone {ms}"); - } - - if !issue.labels.is_empty() { - println!( - " Labels {}", - render::format_labels_bare(&issue.labels, issue.labels.len()) - ); - } - - if let Some(url) = &issue.web_url { - println!(" URL {}", Theme::muted().render(url)); - } - - // Development section - if !issue.closing_merge_requests.is_empty() { - println!("{}", render::section_divider("Development")); - for mr in &issue.closing_merge_requests { - let (mr_icon, mr_style) = match mr.state.as_str() { - "merged" => (Icons::mr_merged(), Theme::accent()), - "opened" => (Icons::mr_opened(), Theme::success()), - "closed" => (Icons::mr_closed(), Theme::error()), - _ => (Icons::mr_opened(), Theme::dim()), - }; - println!( - " {} !{} {} {}", - mr_style.render(mr_icon), - mr.iid, - mr.title, - mr_style.render(&mr.state), - ); - } - } - - // Description section - println!("{}", render::section_divider("Description")); - if let Some(desc) = &issue.description { - let wrapped = wrap_text(desc, 72, " "); - println!(" {wrapped}"); - } else { - println!(" {}", Theme::muted().render("(no description)")); - } - - // Discussions section - let user_discussions: Vec<&DiscussionDetail> = issue - .discussions - .iter() - .filter(|d| d.notes.iter().any(|n| !n.is_system)) - .collect(); - - if user_discussions.is_empty() { - println!("\n {}", Theme::muted().render("No discussions")); - } else { - println!( - "{}", - render::section_divider(&format!("Discussions ({})", user_discussions.len())) - ); - - for discussion in user_discussions { - let user_notes: Vec<&NoteDetail> = - discussion.notes.iter().filter(|n| !n.is_system).collect(); - - if let Some(first_note) = user_notes.first() { - println!( - " {} {}", - Theme::info().render(&format!("@{}", first_note.author_username)), - format_date(first_note.created_at), - ); - let wrapped = wrap_text(&first_note.body, 68, " "); - println!(" {wrapped}"); - println!(); - - for reply in user_notes.iter().skip(1) { - println!( - " {} {}", - Theme::info().render(&format!("@{}", reply.author_username)), - format_date(reply.created_at), - ); - let wrapped = wrap_text(&reply.body, 66, " "); - println!(" {wrapped}"); - println!(); - } - } - } - } -} - -pub fn print_show_mr(mr: &MrDetail) { - // Title line - let draft_prefix = if mr.draft { - format!("{} ", Icons::mr_draft()) - } else { - String::new() - }; - println!( - " MR !{}: {}{}", - mr.iid, - draft_prefix, - Theme::bold().render(&mr.title), - ); - - // Details section - println!("{}", render::section_divider("Details")); - - println!(" Project {}", Theme::info().render(&mr.project_path)); - - let (icon, state_style) = match mr.state.as_str() { - "opened" => (Icons::mr_opened(), Theme::success()), - "merged" => (Icons::mr_merged(), Theme::accent()), - "closed" => (Icons::mr_closed(), Theme::error()), - _ => (Icons::mr_opened(), Theme::dim()), - }; - println!( - " State {}", - state_style.render(&format!("{icon} {}", mr.state)) - ); - - println!( - " Branches {} -> {}", - Theme::info().render(&mr.source_branch), - Theme::warning().render(&mr.target_branch) - ); - - println!(" Author @{}", mr.author_username); - - if !mr.assignees.is_empty() { - println!( - " Assignees {}", - mr.assignees - .iter() - .map(|a| format!("@{a}")) - .collect::>() - .join(", ") - ); - } - - if !mr.reviewers.is_empty() { - println!( - " Reviewers {}", - mr.reviewers - .iter() - .map(|r| format!("@{r}")) - .collect::>() - .join(", ") - ); - } - - println!( - " Created {} ({})", - format_date(mr.created_at), - render::format_relative_time_compact(mr.created_at), - ); - println!( - " Updated {} ({})", - format_date(mr.updated_at), - render::format_relative_time_compact(mr.updated_at), - ); - - if let Some(merged_at) = mr.merged_at { - println!( - " Merged {} ({})", - format_date(merged_at), - render::format_relative_time_compact(merged_at), - ); - } - - if let Some(closed_at) = mr.closed_at { - println!( - " Closed {} ({})", - format_date(closed_at), - render::format_relative_time_compact(closed_at), - ); - } - - if !mr.labels.is_empty() { - println!( - " Labels {}", - render::format_labels_bare(&mr.labels, mr.labels.len()) - ); - } - - if let Some(url) = &mr.web_url { - println!(" URL {}", Theme::muted().render(url)); - } - - // Description section - println!("{}", render::section_divider("Description")); - if let Some(desc) = &mr.description { - let wrapped = wrap_text(desc, 72, " "); - println!(" {wrapped}"); - } else { - println!(" {}", Theme::muted().render("(no description)")); - } - - // Discussions section - let user_discussions: Vec<&MrDiscussionDetail> = mr - .discussions - .iter() - .filter(|d| d.notes.iter().any(|n| !n.is_system)) - .collect(); - - if user_discussions.is_empty() { - println!("\n {}", Theme::muted().render("No discussions")); - } else { - println!( - "{}", - render::section_divider(&format!("Discussions ({})", user_discussions.len())) - ); - - for discussion in user_discussions { - let user_notes: Vec<&MrNoteDetail> = - discussion.notes.iter().filter(|n| !n.is_system).collect(); - - if let Some(first_note) = user_notes.first() { - if let Some(pos) = &first_note.position { - print_diff_position(pos); - } - - println!( - " {} {}", - Theme::info().render(&format!("@{}", first_note.author_username)), - format_date(first_note.created_at), - ); - let wrapped = wrap_text(&first_note.body, 68, " "); - println!(" {wrapped}"); - println!(); - - for reply in user_notes.iter().skip(1) { - println!( - " {} {}", - Theme::info().render(&format!("@{}", reply.author_username)), - format_date(reply.created_at), - ); - let wrapped = wrap_text(&reply.body, 66, " "); - println!(" {wrapped}"); - println!(); - } - } - } - } -} - -fn print_diff_position(pos: &DiffNotePosition) { - let file = pos.new_path.as_ref().or(pos.old_path.as_ref()); - - if let Some(file_path) = file { - let line_str = match (pos.old_line, pos.new_line) { - (Some(old), Some(new)) if old == new => format!(":{}", new), - (Some(old), Some(new)) => format!(":{}→{}", old, new), - (None, Some(new)) => format!(":+{}", new), - (Some(old), None) => format!(":-{}", old), - (None, None) => String::new(), - }; - - println!( - " {} {}{}", - Theme::dim().render("\u{1f4cd}"), - Theme::warning().render(file_path), - Theme::dim().render(&line_str) - ); - } -} - -#[derive(Serialize)] -pub struct IssueDetailJson { - pub id: i64, - pub iid: i64, - pub title: String, - pub description: Option, - pub state: String, - pub author_username: String, - pub created_at: String, - pub updated_at: String, - pub closed_at: Option, - pub confidential: bool, - pub web_url: Option, - pub project_path: String, - pub references_full: String, - pub labels: Vec, - pub assignees: Vec, - pub due_date: Option, - pub milestone: Option, - pub user_notes_count: i64, - pub merge_requests_count: usize, - pub closing_merge_requests: Vec, - pub discussions: Vec, - pub status_name: Option, - #[serde(skip_serializing)] - pub status_category: Option, - pub status_color: Option, - pub status_icon_name: Option, - pub status_synced_at: Option, -} - -#[derive(Serialize)] -pub struct ClosingMrRefJson { - pub iid: i64, - pub title: String, - pub state: String, - pub web_url: Option, -} - -#[derive(Serialize)] -pub struct DiscussionDetailJson { - pub notes: Vec, - pub individual_note: bool, -} - -#[derive(Serialize)] -pub struct NoteDetailJson { - pub author_username: String, - pub body: String, - pub created_at: String, - pub is_system: bool, -} - -impl From<&IssueDetail> for IssueDetailJson { - fn from(issue: &IssueDetail) -> Self { - Self { - id: issue.id, - iid: issue.iid, - title: issue.title.clone(), - description: issue.description.clone(), - state: issue.state.clone(), - author_username: issue.author_username.clone(), - created_at: ms_to_iso(issue.created_at), - updated_at: ms_to_iso(issue.updated_at), - closed_at: issue.closed_at.clone(), - confidential: issue.confidential, - web_url: issue.web_url.clone(), - project_path: issue.project_path.clone(), - references_full: issue.references_full.clone(), - labels: issue.labels.clone(), - assignees: issue.assignees.clone(), - due_date: issue.due_date.clone(), - milestone: issue.milestone.clone(), - user_notes_count: issue.user_notes_count, - merge_requests_count: issue.merge_requests_count, - closing_merge_requests: issue - .closing_merge_requests - .iter() - .map(|mr| ClosingMrRefJson { - iid: mr.iid, - title: mr.title.clone(), - state: mr.state.clone(), - web_url: mr.web_url.clone(), - }) - .collect(), - discussions: issue.discussions.iter().map(|d| d.into()).collect(), - status_name: issue.status_name.clone(), - status_category: issue.status_category.clone(), - status_color: issue.status_color.clone(), - status_icon_name: issue.status_icon_name.clone(), - status_synced_at: issue.status_synced_at.map(ms_to_iso), - } - } -} - -impl From<&DiscussionDetail> for DiscussionDetailJson { - fn from(disc: &DiscussionDetail) -> Self { - Self { - notes: disc.notes.iter().map(|n| n.into()).collect(), - individual_note: disc.individual_note, - } - } -} - -impl From<&NoteDetail> for NoteDetailJson { - fn from(note: &NoteDetail) -> Self { - Self { - author_username: note.author_username.clone(), - body: note.body.clone(), - created_at: ms_to_iso(note.created_at), - is_system: note.is_system, - } - } -} - -#[derive(Serialize)] -pub struct MrDetailJson { - pub id: i64, - pub iid: i64, - pub title: String, - pub description: Option, - pub state: String, - pub draft: bool, - pub author_username: String, - pub source_branch: String, - pub target_branch: String, - pub created_at: String, - pub updated_at: String, - pub merged_at: Option, - pub closed_at: Option, - pub web_url: Option, - pub project_path: String, - pub labels: Vec, - pub assignees: Vec, - pub reviewers: Vec, - pub discussions: Vec, -} - -#[derive(Serialize)] -pub struct MrDiscussionDetailJson { - pub notes: Vec, - pub individual_note: bool, -} - -#[derive(Serialize)] -pub struct MrNoteDetailJson { - pub author_username: String, - pub body: String, - pub created_at: String, - pub is_system: bool, - pub position: Option, -} - -impl From<&MrDetail> for MrDetailJson { - fn from(mr: &MrDetail) -> Self { - Self { - id: mr.id, - iid: mr.iid, - title: mr.title.clone(), - description: mr.description.clone(), - state: mr.state.clone(), - draft: mr.draft, - author_username: mr.author_username.clone(), - source_branch: mr.source_branch.clone(), - target_branch: mr.target_branch.clone(), - created_at: ms_to_iso(mr.created_at), - updated_at: ms_to_iso(mr.updated_at), - merged_at: mr.merged_at.map(ms_to_iso), - closed_at: mr.closed_at.map(ms_to_iso), - web_url: mr.web_url.clone(), - project_path: mr.project_path.clone(), - labels: mr.labels.clone(), - assignees: mr.assignees.clone(), - reviewers: mr.reviewers.clone(), - discussions: mr.discussions.iter().map(|d| d.into()).collect(), - } - } -} - -impl From<&MrDiscussionDetail> for MrDiscussionDetailJson { - fn from(disc: &MrDiscussionDetail) -> Self { - Self { - notes: disc.notes.iter().map(|n| n.into()).collect(), - individual_note: disc.individual_note, - } - } -} - -impl From<&MrNoteDetail> for MrNoteDetailJson { - fn from(note: &MrNoteDetail) -> Self { - Self { - author_username: note.author_username.clone(), - body: note.body.clone(), - created_at: ms_to_iso(note.created_at), - is_system: note.is_system, - position: note.position.clone(), - } - } -} - -pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) { - let json_result = IssueDetailJson::from(issue); - let meta = RobotMeta { elapsed_ms }; - let output = serde_json::json!({ - "ok": true, - "data": json_result, - "meta": meta, - }); - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) { - let json_result = MrDetailJson::from(mr); - let meta = RobotMeta { elapsed_ms }; - let output = serde_json::json!({ - "ok": true, - "data": json_result, - "meta": meta, - }); - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::db::run_migrations; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn seed_project(conn: &Connection) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) - VALUES (1, 100, 'group/repo', 'https://gitlab.example.com', 1000, 2000)", - [], - ) - .unwrap(); - } - - fn seed_issue(conn: &Connection) { - seed_project(conn); - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, - created_at, updated_at, last_seen_at) - VALUES (1, 200, 10, 1, 'Test issue', 'opened', 'author', 1000, 2000, 2000)", - [], - ) - .unwrap(); - } - - fn seed_second_project(conn: &Connection) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) - VALUES (2, 101, 'other/repo', 'https://gitlab.example.com/other', 1000, 2000)", - [], - ) - .unwrap(); - } - - fn seed_discussion_with_notes( - conn: &Connection, - issue_id: i64, - project_id: i64, - user_notes: usize, - system_notes: usize, - ) { - let disc_id: i64 = conn - .query_row( - "SELECT COALESCE(MAX(id), 0) + 1 FROM discussions", - [], - |r| r.get(0), - ) - .unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at) - VALUES (?1, ?2, ?3, ?4, 'Issue', 1000, 2000, 2000)", - rusqlite::params![disc_id, format!("disc-{}", disc_id), project_id, issue_id], - ) - .unwrap(); - for i in 0..user_notes { - conn.execute( - "INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position) - VALUES (?1, ?2, ?3, 'user1', 'comment', 1000, 2000, 2000, 0, ?4)", - rusqlite::params![1000 + disc_id * 100 + i as i64, disc_id, project_id, i as i64], - ) - .unwrap(); - } - for i in 0..system_notes { - conn.execute( - "INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position) - VALUES (?1, ?2, ?3, 'system', 'status changed', 1000, 2000, 2000, 1, ?4)", - rusqlite::params![2000 + disc_id * 100 + i as i64, disc_id, project_id, (user_notes + i) as i64], - ) - .unwrap(); - } - } - - // --- find_issue tests --- - - #[test] - fn test_find_issue_basic() { - let conn = setup_test_db(); - seed_issue(&conn); - let row = find_issue(&conn, 10, None).unwrap(); - assert_eq!(row.iid, 10); - assert_eq!(row.title, "Test issue"); - assert_eq!(row.state, "opened"); - assert_eq!(row.author_username, "author"); - assert_eq!(row.project_path, "group/repo"); - } - - #[test] - fn test_find_issue_with_project_filter() { - let conn = setup_test_db(); - seed_issue(&conn); - let row = find_issue(&conn, 10, Some("group/repo")).unwrap(); - assert_eq!(row.iid, 10); - assert_eq!(row.project_path, "group/repo"); - } - - #[test] - fn test_find_issue_not_found() { - let conn = setup_test_db(); - seed_issue(&conn); - let err = find_issue(&conn, 999, None).unwrap_err(); - assert!(matches!(err, LoreError::NotFound(_))); - } - - #[test] - fn test_find_issue_wrong_project_filter() { - let conn = setup_test_db(); - seed_issue(&conn); - seed_second_project(&conn); - // Issue 10 only exists in project 1, not project 2 - let err = find_issue(&conn, 10, Some("other/repo")).unwrap_err(); - assert!(matches!(err, LoreError::NotFound(_))); - } - - #[test] - fn test_find_issue_ambiguous_without_project() { - let conn = setup_test_db(); - seed_issue(&conn); // issue iid=10 in project 1 - seed_second_project(&conn); - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, - created_at, updated_at, last_seen_at) - VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)", - [], - ) - .unwrap(); - let err = find_issue(&conn, 10, None).unwrap_err(); - assert!(matches!(err, LoreError::Ambiguous(_))); - } - - #[test] - fn test_find_issue_ambiguous_resolved_with_project() { - let conn = setup_test_db(); - seed_issue(&conn); - seed_second_project(&conn); - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, - created_at, updated_at, last_seen_at) - VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)", - [], - ) - .unwrap(); - let row = find_issue(&conn, 10, Some("other/repo")).unwrap(); - assert_eq!(row.title, "Same iid different project"); - } - - #[test] - fn test_find_issue_user_notes_count_zero() { - let conn = setup_test_db(); - seed_issue(&conn); - let row = find_issue(&conn, 10, None).unwrap(); - assert_eq!(row.user_notes_count, 0); - } - - #[test] - fn test_find_issue_user_notes_count_excludes_system() { - let conn = setup_test_db(); - seed_issue(&conn); - // 2 user notes + 3 system notes = should count only 2 - seed_discussion_with_notes(&conn, 1, 1, 2, 3); - let row = find_issue(&conn, 10, None).unwrap(); - assert_eq!(row.user_notes_count, 2); - } - - #[test] - fn test_find_issue_user_notes_count_across_discussions() { - let conn = setup_test_db(); - seed_issue(&conn); - seed_discussion_with_notes(&conn, 1, 1, 3, 0); // 3 user notes - seed_discussion_with_notes(&conn, 1, 1, 1, 2); // 1 user note + 2 system - let row = find_issue(&conn, 10, None).unwrap(); - assert_eq!(row.user_notes_count, 4); - } - - #[test] - fn test_find_issue_notes_count_ignores_other_issues() { - let conn = setup_test_db(); - seed_issue(&conn); - // Add a second issue - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, - created_at, updated_at, last_seen_at) - VALUES (2, 201, 20, 1, 'Other issue', 'opened', 'author', 1000, 2000, 2000)", - [], - ) - .unwrap(); - // Notes on issue 2, not issue 1 - seed_discussion_with_notes(&conn, 2, 1, 5, 0); - let row = find_issue(&conn, 10, None).unwrap(); - assert_eq!(row.user_notes_count, 0); // Issue 10 has no notes - } - - #[test] - fn test_ansi256_from_rgb() { - // Moved to render.rs — keeping basic hex sanity check - let result = render::style_with_hex("test", Some("#ff0000")); - assert!(!result.is_empty()); - } - - #[test] - fn test_get_issue_assignees_empty() { - let conn = setup_test_db(); - seed_issue(&conn); - let result = get_issue_assignees(&conn, 1).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn test_get_issue_assignees_single() { - let conn = setup_test_db(); - seed_issue(&conn); - conn.execute( - "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'charlie')", - [], - ) - .unwrap(); - let result = get_issue_assignees(&conn, 1).unwrap(); - assert_eq!(result, vec!["charlie"]); - } - - #[test] - fn test_get_issue_assignees_multiple_sorted() { - let conn = setup_test_db(); - seed_issue(&conn); - conn.execute( - "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'bob')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')", - [], - ) - .unwrap(); - let result = get_issue_assignees(&conn, 1).unwrap(); - assert_eq!(result, vec!["alice", "bob"]); // alphabetical - } - - #[test] - fn test_get_closing_mrs_empty() { - let conn = setup_test_db(); - seed_issue(&conn); - let result = get_closing_mrs(&conn, 1).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn test_get_closing_mrs_single() { - let conn = setup_test_db(); - seed_issue(&conn); - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, - source_branch, target_branch, created_at, updated_at, last_seen_at) - VALUES (1, 300, 5, 1, 'Fix the bug', 'merged', 'dev', 'fix', 'main', 1000, 2000, 2000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, - target_entity_type, target_entity_id, reference_type, source_method, created_at) - VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", - [], - ) - .unwrap(); - let result = get_closing_mrs(&conn, 1).unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].iid, 5); - assert_eq!(result[0].title, "Fix the bug"); - assert_eq!(result[0].state, "merged"); - } - - #[test] - fn test_get_closing_mrs_ignores_mentioned() { - let conn = setup_test_db(); - seed_issue(&conn); - // Add a 'mentioned' reference that should be ignored - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, - source_branch, target_branch, created_at, updated_at, last_seen_at) - VALUES (1, 300, 5, 1, 'Some MR', 'opened', 'dev', 'feat', 'main', 1000, 2000, 2000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, - target_entity_type, target_entity_id, reference_type, source_method, created_at) - VALUES (1, 'merge_request', 1, 'issue', 1, 'mentioned', 'note_parse', 3000)", - [], - ) - .unwrap(); - let result = get_closing_mrs(&conn, 1).unwrap(); - assert!(result.is_empty()); // 'mentioned' refs not included - } - - #[test] - fn test_get_closing_mrs_multiple_sorted() { - let conn = setup_test_db(); - seed_issue(&conn); - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, - source_branch, target_branch, created_at, updated_at, last_seen_at) - VALUES (1, 300, 8, 1, 'Second fix', 'opened', 'dev', 'fix2', 'main', 1000, 2000, 2000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, - source_branch, target_branch, created_at, updated_at, last_seen_at) - VALUES (2, 301, 5, 1, 'First fix', 'merged', 'dev', 'fix1', 'main', 1000, 2000, 2000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, - target_entity_type, target_entity_id, reference_type, source_method, created_at) - VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, - target_entity_type, target_entity_id, reference_type, source_method, created_at) - VALUES (1, 'merge_request', 2, 'issue', 1, 'closes', 'api', 3000)", - [], - ) - .unwrap(); - let result = get_closing_mrs(&conn, 1).unwrap(); - assert_eq!(result.len(), 2); - assert_eq!(result[0].iid, 5); // Lower iid first - assert_eq!(result[1].iid, 8); - } - - #[test] - fn wrap_text_single_line() { - assert_eq!(wrap_text("hello world", 80, " "), "hello world"); - } - - #[test] - fn wrap_text_multiple_lines() { - let result = wrap_text("one two three four five", 10, " "); - assert!(result.contains('\n')); - } - - #[test] - fn format_date_extracts_date_part() { - let ms = 1705276800000; - let date = format_date(ms); - assert!(date.starts_with("2024-01-15")); - } -} diff --git a/src/cli/commands/show/issue.rs b/src/cli/commands/show/issue.rs new file mode 100644 index 0000000..094bae9 --- /dev/null +++ b/src/cli/commands/show/issue.rs @@ -0,0 +1,310 @@ +#[derive(Debug, Clone, Serialize)] +pub struct ClosingMrRef { + pub iid: i64, + pub title: String, + pub state: String, + pub web_url: Option, +} + +#[derive(Debug, Serialize)] +pub struct IssueDetail { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub author_username: String, + pub created_at: i64, + pub updated_at: i64, + pub closed_at: Option, + pub confidential: bool, + pub web_url: Option, + pub project_path: String, + pub references_full: String, + pub labels: Vec, + pub assignees: Vec, + pub due_date: Option, + pub milestone: Option, + pub user_notes_count: i64, + pub merge_requests_count: usize, + pub closing_merge_requests: Vec, + pub discussions: Vec, + pub status_name: Option, + pub status_category: Option, + pub status_color: Option, + pub status_icon_name: Option, + pub status_synced_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct DiscussionDetail { + pub notes: Vec, + pub individual_note: bool, +} + +#[derive(Debug, Serialize)] +pub struct NoteDetail { + pub author_username: String, + pub body: String, + pub created_at: i64, + pub is_system: bool, +} + +pub fn run_show_issue( + config: &Config, + iid: i64, + project_filter: Option<&str>, +) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let issue = find_issue(&conn, iid, project_filter)?; + + let labels = get_issue_labels(&conn, issue.id)?; + + let assignees = get_issue_assignees(&conn, issue.id)?; + + let closing_mrs = get_closing_mrs(&conn, issue.id)?; + + let discussions = get_issue_discussions(&conn, issue.id)?; + + let references_full = format!("{}#{}", issue.project_path, issue.iid); + let merge_requests_count = closing_mrs.len(); + + Ok(IssueDetail { + id: issue.id, + iid: issue.iid, + title: issue.title, + description: issue.description, + state: issue.state, + author_username: issue.author_username, + created_at: issue.created_at, + updated_at: issue.updated_at, + closed_at: issue.closed_at, + confidential: issue.confidential, + web_url: issue.web_url, + project_path: issue.project_path, + references_full, + labels, + assignees, + due_date: issue.due_date, + milestone: issue.milestone_title, + user_notes_count: issue.user_notes_count, + merge_requests_count, + closing_merge_requests: closing_mrs, + discussions, + status_name: issue.status_name, + status_category: issue.status_category, + status_color: issue.status_color, + status_icon_name: issue.status_icon_name, + status_synced_at: issue.status_synced_at, + }) +} + +#[derive(Debug)] +struct IssueRow { + id: i64, + iid: i64, + title: String, + description: Option, + state: String, + author_username: String, + created_at: i64, + updated_at: i64, + closed_at: Option, + confidential: bool, + web_url: Option, + project_path: String, + due_date: Option, + milestone_title: Option, + user_notes_count: i64, + status_name: Option, + status_category: Option, + status_color: Option, + status_icon_name: Option, + status_synced_at: Option, +} + +fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { + let (sql, params): (&str, Vec>) = match project_filter { + Some(project) => { + let project_id = resolve_project(conn, project)?; + ( + "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, + i.created_at, i.updated_at, i.closed_at, i.confidential, + i.web_url, p.path_with_namespace, + i.due_date, i.milestone_title, + (SELECT COUNT(*) FROM notes n + JOIN discussions d ON n.discussion_id = d.id + WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count, + i.status_name, i.status_category, i.status_color, + i.status_icon_name, i.status_synced_at + FROM issues i + JOIN projects p ON i.project_id = p.id + WHERE i.iid = ? AND i.project_id = ?", + vec![Box::new(iid), Box::new(project_id)], + ) + } + None => ( + "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, + i.created_at, i.updated_at, i.closed_at, i.confidential, + i.web_url, p.path_with_namespace, + i.due_date, i.milestone_title, + (SELECT COUNT(*) FROM notes n + JOIN discussions d ON n.discussion_id = d.id + WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_count, + i.status_name, i.status_category, i.status_color, + i.status_icon_name, i.status_synced_at + FROM issues i + JOIN projects p ON i.project_id = p.id + WHERE i.iid = ?", + vec![Box::new(iid)], + ), + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(sql)?; + let issues: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + let confidential_val: i64 = row.get(9)?; + Ok(IssueRow { + id: row.get(0)?, + iid: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + state: row.get(4)?, + author_username: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + closed_at: row.get(8)?, + confidential: confidential_val != 0, + web_url: row.get(10)?, + project_path: row.get(11)?, + due_date: row.get(12)?, + milestone_title: row.get(13)?, + user_notes_count: row.get(14)?, + status_name: row.get(15)?, + status_category: row.get(16)?, + status_color: row.get(17)?, + status_icon_name: row.get(18)?, + status_synced_at: row.get(19)?, + }) + })? + .collect::, _>>()?; + + match issues.len() { + 0 => Err(LoreError::NotFound(format!("Issue #{} not found", iid))), + 1 => Ok(issues.into_iter().next().unwrap()), + _ => { + let projects: Vec = issues.iter().map(|i| i.project_path.clone()).collect(); + Err(LoreError::Ambiguous(format!( + "Issue #{} exists in multiple projects: {}. Use --project to specify.", + iid, + projects.join(", ") + ))) + } + } +} + +fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT l.name FROM labels l + JOIN issue_labels il ON l.id = il.label_id + WHERE il.issue_id = ? + ORDER BY l.name", + )?; + + let labels: Vec = stmt + .query_map([issue_id], |row| row.get(0))? + .collect::, _>>()?; + + Ok(labels) +} + +fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT username FROM issue_assignees + WHERE issue_id = ? + ORDER BY username", + )?; + + let assignees: Vec = stmt + .query_map([issue_id], |row| row.get(0))? + .collect::, _>>()?; + + Ok(assignees) +} + +fn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT mr.iid, mr.title, mr.state, mr.web_url + FROM entity_references er + JOIN merge_requests mr ON mr.id = er.source_entity_id + WHERE er.target_entity_type = 'issue' + AND er.target_entity_id = ? + AND er.source_entity_type = 'merge_request' + AND er.reference_type = 'closes' + ORDER BY mr.iid", + )?; + + let mrs: Vec = stmt + .query_map([issue_id], |row| { + Ok(ClosingMrRef { + iid: row.get(0)?, + title: row.get(1)?, + state: row.get(2)?, + web_url: row.get(3)?, + }) + })? + .collect::, _>>()?; + + Ok(mrs) +} + +fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { + let mut disc_stmt = conn.prepare( + "SELECT id, individual_note FROM discussions + WHERE issue_id = ? + ORDER BY first_note_at", + )?; + + let disc_rows: Vec<(i64, bool)> = disc_stmt + .query_map([issue_id], |row| { + let individual: i64 = row.get(1)?; + Ok((row.get(0)?, individual == 1)) + })? + .collect::, _>>()?; + + let mut note_stmt = conn.prepare( + "SELECT author_username, body, created_at, is_system + FROM notes + WHERE discussion_id = ? + ORDER BY position", + )?; + + let mut discussions = Vec::new(); + for (disc_id, individual_note) in disc_rows { + let notes: Vec = note_stmt + .query_map([disc_id], |row| { + let is_system: i64 = row.get(3)?; + Ok(NoteDetail { + author_username: row.get(0)?, + body: row.get(1)?, + created_at: row.get(2)?, + is_system: is_system == 1, + }) + })? + .collect::, _>>()?; + + let has_user_notes = notes.iter().any(|n| !n.is_system); + if has_user_notes || notes.is_empty() { + discussions.push(DiscussionDetail { + notes, + individual_note, + }); + } + } + + Ok(discussions) +} diff --git a/src/cli/commands/show/mod.rs b/src/cli/commands/show/mod.rs new file mode 100644 index 0000000..e9c602a --- /dev/null +++ b/src/cli/commands/show/mod.rs @@ -0,0 +1,19 @@ +use crate::cli::render::{self, Icons, Theme}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::Config; +use crate::cli::robot::RobotMeta; +use crate::core::db::create_connection; +use crate::core::error::{LoreError, Result}; +use crate::core::paths::get_db_path; +use crate::core::project::resolve_project; +use crate::core::time::ms_to_iso; + +include!("issue.rs"); +include!("mr.rs"); +include!("render.rs"); + +#[cfg(test)] +#[path = "show_tests.rs"] +mod tests; diff --git a/src/cli/commands/show/mr.rs b/src/cli/commands/show/mr.rs new file mode 100644 index 0000000..22bf009 --- /dev/null +++ b/src/cli/commands/show/mr.rs @@ -0,0 +1,283 @@ +#[derive(Debug, Serialize)] +pub struct MrDetail { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub draft: bool, + pub author_username: String, + pub source_branch: String, + pub target_branch: String, + pub created_at: i64, + pub updated_at: i64, + pub merged_at: Option, + pub closed_at: Option, + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub assignees: Vec, + pub reviewers: Vec, + pub discussions: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MrDiscussionDetail { + pub notes: Vec, + pub individual_note: bool, +} + +#[derive(Debug, Serialize)] +pub struct MrNoteDetail { + pub author_username: String, + pub body: String, + pub created_at: i64, + pub is_system: bool, + pub position: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DiffNotePosition { + pub old_path: Option, + pub new_path: Option, + pub old_line: Option, + pub new_line: Option, + pub position_type: Option, +} +pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let mr = find_mr(&conn, iid, project_filter)?; + + let labels = get_mr_labels(&conn, mr.id)?; + + let assignees = get_mr_assignees(&conn, mr.id)?; + + let reviewers = get_mr_reviewers(&conn, mr.id)?; + + let discussions = get_mr_discussions(&conn, mr.id)?; + + Ok(MrDetail { + id: mr.id, + iid: mr.iid, + title: mr.title, + description: mr.description, + state: mr.state, + draft: mr.draft, + author_username: mr.author_username, + source_branch: mr.source_branch, + target_branch: mr.target_branch, + created_at: mr.created_at, + updated_at: mr.updated_at, + merged_at: mr.merged_at, + closed_at: mr.closed_at, + web_url: mr.web_url, + project_path: mr.project_path, + labels, + assignees, + reviewers, + discussions, + }) +} + +struct MrRow { + id: i64, + iid: i64, + title: String, + description: Option, + state: String, + draft: bool, + author_username: String, + source_branch: String, + target_branch: String, + created_at: i64, + updated_at: i64, + merged_at: Option, + closed_at: Option, + web_url: Option, + project_path: String, +} + +fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { + let (sql, params): (&str, Vec>) = match project_filter { + Some(project) => { + let project_id = resolve_project(conn, project)?; + ( + "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, + m.author_username, m.source_branch, m.target_branch, + m.created_at, m.updated_at, m.merged_at, m.closed_at, + m.web_url, p.path_with_namespace + FROM merge_requests m + JOIN projects p ON m.project_id = p.id + WHERE m.iid = ? AND m.project_id = ?", + vec![Box::new(iid), Box::new(project_id)], + ) + } + None => ( + "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, + m.author_username, m.source_branch, m.target_branch, + m.created_at, m.updated_at, m.merged_at, m.closed_at, + m.web_url, p.path_with_namespace + FROM merge_requests m + JOIN projects p ON m.project_id = p.id + WHERE m.iid = ?", + vec![Box::new(iid)], + ), + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(sql)?; + let mrs: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + let draft_val: i64 = row.get(5)?; + Ok(MrRow { + id: row.get(0)?, + iid: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + state: row.get(4)?, + draft: draft_val == 1, + author_username: row.get(6)?, + source_branch: row.get(7)?, + target_branch: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + merged_at: row.get(11)?, + closed_at: row.get(12)?, + web_url: row.get(13)?, + project_path: row.get(14)?, + }) + })? + .collect::, _>>()?; + + match mrs.len() { + 0 => Err(LoreError::NotFound(format!("MR !{} not found", iid))), + 1 => Ok(mrs.into_iter().next().unwrap()), + _ => { + let projects: Vec = mrs.iter().map(|m| m.project_path.clone()).collect(); + Err(LoreError::Ambiguous(format!( + "MR !{} exists in multiple projects: {}. Use --project to specify.", + iid, + projects.join(", ") + ))) + } + } +} + +fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT l.name FROM labels l + JOIN mr_labels ml ON l.id = ml.label_id + WHERE ml.merge_request_id = ? + ORDER BY l.name", + )?; + + let labels: Vec = stmt + .query_map([mr_id], |row| row.get(0))? + .collect::, _>>()?; + + Ok(labels) +} + +fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT username FROM mr_assignees + WHERE merge_request_id = ? + ORDER BY username", + )?; + + let assignees: Vec = stmt + .query_map([mr_id], |row| row.get(0))? + .collect::, _>>()?; + + Ok(assignees) +} + +fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT username FROM mr_reviewers + WHERE merge_request_id = ? + ORDER BY username", + )?; + + let reviewers: Vec = stmt + .query_map([mr_id], |row| row.get(0))? + .collect::, _>>()?; + + Ok(reviewers) +} + +fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result> { + let mut disc_stmt = conn.prepare( + "SELECT id, individual_note FROM discussions + WHERE merge_request_id = ? + ORDER BY first_note_at", + )?; + + let disc_rows: Vec<(i64, bool)> = disc_stmt + .query_map([mr_id], |row| { + let individual: i64 = row.get(1)?; + Ok((row.get(0)?, individual == 1)) + })? + .collect::, _>>()?; + + let mut note_stmt = conn.prepare( + "SELECT author_username, body, created_at, is_system, + position_old_path, position_new_path, position_old_line, + position_new_line, position_type + FROM notes + WHERE discussion_id = ? + ORDER BY position", + )?; + + let mut discussions = Vec::new(); + for (disc_id, individual_note) in disc_rows { + let notes: Vec = note_stmt + .query_map([disc_id], |row| { + let is_system: i64 = row.get(3)?; + let old_path: Option = row.get(4)?; + let new_path: Option = row.get(5)?; + let old_line: Option = row.get(6)?; + let new_line: Option = row.get(7)?; + let position_type: Option = row.get(8)?; + + let position = if old_path.is_some() + || new_path.is_some() + || old_line.is_some() + || new_line.is_some() + { + Some(DiffNotePosition { + old_path, + new_path, + old_line, + new_line, + position_type, + }) + } else { + None + }; + + Ok(MrNoteDetail { + author_username: row.get(0)?, + body: row.get(1)?, + created_at: row.get(2)?, + is_system: is_system == 1, + position, + }) + })? + .collect::, _>>()?; + + let has_user_notes = notes.iter().any(|n| !n.is_system); + if has_user_notes || notes.is_empty() { + discussions.push(MrDiscussionDetail { + notes, + individual_note, + }); + } + } + + Ok(discussions) +} + diff --git a/src/cli/commands/show/render.rs b/src/cli/commands/show/render.rs new file mode 100644 index 0000000..f655219 --- /dev/null +++ b/src/cli/commands/show/render.rs @@ -0,0 +1,580 @@ +fn format_date(ms: i64) -> String { + render::format_date(ms) +} + +fn wrap_text(text: &str, width: usize, indent: &str) -> String { + render::wrap_indent(text, width, indent) +} + +pub fn print_show_issue(issue: &IssueDetail) { + // Title line + println!( + " Issue #{}: {}", + issue.iid, + Theme::bold().render(&issue.title), + ); + + // Details section + println!("{}", render::section_divider("Details")); + + println!( + " Ref {}", + Theme::muted().render(&issue.references_full) + ); + println!( + " Project {}", + Theme::info().render(&issue.project_path) + ); + + let (icon, state_style) = if issue.state == "opened" { + (Icons::issue_opened(), Theme::success()) + } else { + (Icons::issue_closed(), Theme::dim()) + }; + println!( + " State {}", + state_style.render(&format!("{icon} {}", issue.state)) + ); + + if let Some(status) = &issue.status_name { + println!( + " Status {}", + render::style_with_hex(status, issue.status_color.as_deref()) + ); + } + + if issue.confidential { + println!(" {}", Theme::error().bold().render("CONFIDENTIAL")); + } + + println!(" Author @{}", issue.author_username); + + if !issue.assignees.is_empty() { + let label = if issue.assignees.len() > 1 { + "Assignees" + } else { + "Assignee" + }; + println!( + " {}{} {}", + label, + " ".repeat(12 - label.len()), + issue + .assignees + .iter() + .map(|a| format!("@{a}")) + .collect::>() + .join(", ") + ); + } + + println!( + " Created {} ({})", + format_date(issue.created_at), + render::format_relative_time_compact(issue.created_at), + ); + println!( + " Updated {} ({})", + format_date(issue.updated_at), + render::format_relative_time_compact(issue.updated_at), + ); + + if let Some(closed_at) = &issue.closed_at { + println!(" Closed {closed_at}"); + } + + if let Some(due) = &issue.due_date { + println!(" Due {due}"); + } + + if let Some(ms) = &issue.milestone { + println!(" Milestone {ms}"); + } + + if !issue.labels.is_empty() { + println!( + " Labels {}", + render::format_labels_bare(&issue.labels, issue.labels.len()) + ); + } + + if let Some(url) = &issue.web_url { + println!(" URL {}", Theme::muted().render(url)); + } + + // Development section + if !issue.closing_merge_requests.is_empty() { + println!("{}", render::section_divider("Development")); + for mr in &issue.closing_merge_requests { + let (mr_icon, mr_style) = match mr.state.as_str() { + "merged" => (Icons::mr_merged(), Theme::accent()), + "opened" => (Icons::mr_opened(), Theme::success()), + "closed" => (Icons::mr_closed(), Theme::error()), + _ => (Icons::mr_opened(), Theme::dim()), + }; + println!( + " {} !{} {} {}", + mr_style.render(mr_icon), + mr.iid, + mr.title, + mr_style.render(&mr.state), + ); + } + } + + // Description section + println!("{}", render::section_divider("Description")); + if let Some(desc) = &issue.description { + let wrapped = wrap_text(desc, 72, " "); + println!(" {wrapped}"); + } else { + println!(" {}", Theme::muted().render("(no description)")); + } + + // Discussions section + let user_discussions: Vec<&DiscussionDetail> = issue + .discussions + .iter() + .filter(|d| d.notes.iter().any(|n| !n.is_system)) + .collect(); + + if user_discussions.is_empty() { + println!("\n {}", Theme::muted().render("No discussions")); + } else { + println!( + "{}", + render::section_divider(&format!("Discussions ({})", user_discussions.len())) + ); + + for discussion in user_discussions { + let user_notes: Vec<&NoteDetail> = + discussion.notes.iter().filter(|n| !n.is_system).collect(); + + if let Some(first_note) = user_notes.first() { + println!( + " {} {}", + Theme::info().render(&format!("@{}", first_note.author_username)), + format_date(first_note.created_at), + ); + let wrapped = wrap_text(&first_note.body, 68, " "); + println!(" {wrapped}"); + println!(); + + for reply in user_notes.iter().skip(1) { + println!( + " {} {}", + Theme::info().render(&format!("@{}", reply.author_username)), + format_date(reply.created_at), + ); + let wrapped = wrap_text(&reply.body, 66, " "); + println!(" {wrapped}"); + println!(); + } + } + } + } +} + +pub fn print_show_mr(mr: &MrDetail) { + // Title line + let draft_prefix = if mr.draft { + format!("{} ", Icons::mr_draft()) + } else { + String::new() + }; + println!( + " MR !{}: {}{}", + mr.iid, + draft_prefix, + Theme::bold().render(&mr.title), + ); + + // Details section + println!("{}", render::section_divider("Details")); + + println!(" Project {}", Theme::info().render(&mr.project_path)); + + let (icon, state_style) = match mr.state.as_str() { + "opened" => (Icons::mr_opened(), Theme::success()), + "merged" => (Icons::mr_merged(), Theme::accent()), + "closed" => (Icons::mr_closed(), Theme::error()), + _ => (Icons::mr_opened(), Theme::dim()), + }; + println!( + " State {}", + state_style.render(&format!("{icon} {}", mr.state)) + ); + + println!( + " Branches {} -> {}", + Theme::info().render(&mr.source_branch), + Theme::warning().render(&mr.target_branch) + ); + + println!(" Author @{}", mr.author_username); + + if !mr.assignees.is_empty() { + println!( + " Assignees {}", + mr.assignees + .iter() + .map(|a| format!("@{a}")) + .collect::>() + .join(", ") + ); + } + + if !mr.reviewers.is_empty() { + println!( + " Reviewers {}", + mr.reviewers + .iter() + .map(|r| format!("@{r}")) + .collect::>() + .join(", ") + ); + } + + println!( + " Created {} ({})", + format_date(mr.created_at), + render::format_relative_time_compact(mr.created_at), + ); + println!( + " Updated {} ({})", + format_date(mr.updated_at), + render::format_relative_time_compact(mr.updated_at), + ); + + if let Some(merged_at) = mr.merged_at { + println!( + " Merged {} ({})", + format_date(merged_at), + render::format_relative_time_compact(merged_at), + ); + } + + if let Some(closed_at) = mr.closed_at { + println!( + " Closed {} ({})", + format_date(closed_at), + render::format_relative_time_compact(closed_at), + ); + } + + if !mr.labels.is_empty() { + println!( + " Labels {}", + render::format_labels_bare(&mr.labels, mr.labels.len()) + ); + } + + if let Some(url) = &mr.web_url { + println!(" URL {}", Theme::muted().render(url)); + } + + // Description section + println!("{}", render::section_divider("Description")); + if let Some(desc) = &mr.description { + let wrapped = wrap_text(desc, 72, " "); + println!(" {wrapped}"); + } else { + println!(" {}", Theme::muted().render("(no description)")); + } + + // Discussions section + let user_discussions: Vec<&MrDiscussionDetail> = mr + .discussions + .iter() + .filter(|d| d.notes.iter().any(|n| !n.is_system)) + .collect(); + + if user_discussions.is_empty() { + println!("\n {}", Theme::muted().render("No discussions")); + } else { + println!( + "{}", + render::section_divider(&format!("Discussions ({})", user_discussions.len())) + ); + + for discussion in user_discussions { + let user_notes: Vec<&MrNoteDetail> = + discussion.notes.iter().filter(|n| !n.is_system).collect(); + + if let Some(first_note) = user_notes.first() { + if let Some(pos) = &first_note.position { + print_diff_position(pos); + } + + println!( + " {} {}", + Theme::info().render(&format!("@{}", first_note.author_username)), + format_date(first_note.created_at), + ); + let wrapped = wrap_text(&first_note.body, 68, " "); + println!(" {wrapped}"); + println!(); + + for reply in user_notes.iter().skip(1) { + println!( + " {} {}", + Theme::info().render(&format!("@{}", reply.author_username)), + format_date(reply.created_at), + ); + let wrapped = wrap_text(&reply.body, 66, " "); + println!(" {wrapped}"); + println!(); + } + } + } + } +} + +fn print_diff_position(pos: &DiffNotePosition) { + let file = pos.new_path.as_ref().or(pos.old_path.as_ref()); + + if let Some(file_path) = file { + let line_str = match (pos.old_line, pos.new_line) { + (Some(old), Some(new)) if old == new => format!(":{}", new), + (Some(old), Some(new)) => format!(":{}→{}", old, new), + (None, Some(new)) => format!(":+{}", new), + (Some(old), None) => format!(":-{}", old), + (None, None) => String::new(), + }; + + println!( + " {} {}{}", + Theme::dim().render("\u{1f4cd}"), + Theme::warning().render(file_path), + Theme::dim().render(&line_str) + ); + } +} + +#[derive(Serialize)] +pub struct IssueDetailJson { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub author_username: String, + pub created_at: String, + pub updated_at: String, + pub closed_at: Option, + pub confidential: bool, + pub web_url: Option, + pub project_path: String, + pub references_full: String, + pub labels: Vec, + pub assignees: Vec, + pub due_date: Option, + pub milestone: Option, + pub user_notes_count: i64, + pub merge_requests_count: usize, + pub closing_merge_requests: Vec, + pub discussions: Vec, + pub status_name: Option, + #[serde(skip_serializing)] + pub status_category: Option, + pub status_color: Option, + pub status_icon_name: Option, + pub status_synced_at: Option, +} + +#[derive(Serialize)] +pub struct ClosingMrRefJson { + pub iid: i64, + pub title: String, + pub state: String, + pub web_url: Option, +} + +#[derive(Serialize)] +pub struct DiscussionDetailJson { + pub notes: Vec, + pub individual_note: bool, +} + +#[derive(Serialize)] +pub struct NoteDetailJson { + pub author_username: String, + pub body: String, + pub created_at: String, + pub is_system: bool, +} + +impl From<&IssueDetail> for IssueDetailJson { + fn from(issue: &IssueDetail) -> Self { + Self { + id: issue.id, + iid: issue.iid, + title: issue.title.clone(), + description: issue.description.clone(), + state: issue.state.clone(), + author_username: issue.author_username.clone(), + created_at: ms_to_iso(issue.created_at), + updated_at: ms_to_iso(issue.updated_at), + closed_at: issue.closed_at.clone(), + confidential: issue.confidential, + web_url: issue.web_url.clone(), + project_path: issue.project_path.clone(), + references_full: issue.references_full.clone(), + labels: issue.labels.clone(), + assignees: issue.assignees.clone(), + due_date: issue.due_date.clone(), + milestone: issue.milestone.clone(), + user_notes_count: issue.user_notes_count, + merge_requests_count: issue.merge_requests_count, + closing_merge_requests: issue + .closing_merge_requests + .iter() + .map(|mr| ClosingMrRefJson { + iid: mr.iid, + title: mr.title.clone(), + state: mr.state.clone(), + web_url: mr.web_url.clone(), + }) + .collect(), + discussions: issue.discussions.iter().map(|d| d.into()).collect(), + status_name: issue.status_name.clone(), + status_category: issue.status_category.clone(), + status_color: issue.status_color.clone(), + status_icon_name: issue.status_icon_name.clone(), + status_synced_at: issue.status_synced_at.map(ms_to_iso), + } + } +} + +impl From<&DiscussionDetail> for DiscussionDetailJson { + fn from(disc: &DiscussionDetail) -> Self { + Self { + notes: disc.notes.iter().map(|n| n.into()).collect(), + individual_note: disc.individual_note, + } + } +} + +impl From<&NoteDetail> for NoteDetailJson { + fn from(note: &NoteDetail) -> Self { + Self { + author_username: note.author_username.clone(), + body: note.body.clone(), + created_at: ms_to_iso(note.created_at), + is_system: note.is_system, + } + } +} + +#[derive(Serialize)] +pub struct MrDetailJson { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub draft: bool, + pub author_username: String, + pub source_branch: String, + pub target_branch: String, + pub created_at: String, + pub updated_at: String, + pub merged_at: Option, + pub closed_at: Option, + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub assignees: Vec, + pub reviewers: Vec, + pub discussions: Vec, +} + +#[derive(Serialize)] +pub struct MrDiscussionDetailJson { + pub notes: Vec, + pub individual_note: bool, +} + +#[derive(Serialize)] +pub struct MrNoteDetailJson { + pub author_username: String, + pub body: String, + pub created_at: String, + pub is_system: bool, + pub position: Option, +} + +impl From<&MrDetail> for MrDetailJson { + fn from(mr: &MrDetail) -> Self { + Self { + id: mr.id, + iid: mr.iid, + title: mr.title.clone(), + description: mr.description.clone(), + state: mr.state.clone(), + draft: mr.draft, + author_username: mr.author_username.clone(), + source_branch: mr.source_branch.clone(), + target_branch: mr.target_branch.clone(), + created_at: ms_to_iso(mr.created_at), + updated_at: ms_to_iso(mr.updated_at), + merged_at: mr.merged_at.map(ms_to_iso), + closed_at: mr.closed_at.map(ms_to_iso), + web_url: mr.web_url.clone(), + project_path: mr.project_path.clone(), + labels: mr.labels.clone(), + assignees: mr.assignees.clone(), + reviewers: mr.reviewers.clone(), + discussions: mr.discussions.iter().map(|d| d.into()).collect(), + } + } +} + +impl From<&MrDiscussionDetail> for MrDiscussionDetailJson { + fn from(disc: &MrDiscussionDetail) -> Self { + Self { + notes: disc.notes.iter().map(|n| n.into()).collect(), + individual_note: disc.individual_note, + } + } +} + +impl From<&MrNoteDetail> for MrNoteDetailJson { + fn from(note: &MrNoteDetail) -> Self { + Self { + author_username: note.author_username.clone(), + body: note.body.clone(), + created_at: ms_to_iso(note.created_at), + is_system: note.is_system, + position: note.position.clone(), + } + } +} + +pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) { + let json_result = IssueDetailJson::from(issue); + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) { + let json_result = MrDetailJson::from(mr); + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} diff --git a/src/cli/commands/show/show_tests.rs b/src/cli/commands/show/show_tests.rs new file mode 100644 index 0000000..9c07f93 --- /dev/null +++ b/src/cli/commands/show/show_tests.rs @@ -0,0 +1,353 @@ +use super::*; +use crate::core::db::run_migrations; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn seed_project(conn: &Connection) { + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) + VALUES (1, 100, 'group/repo', 'https://gitlab.example.com', 1000, 2000)", + [], + ) + .unwrap(); +} + +fn seed_issue(conn: &Connection) { + seed_project(conn); + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, + created_at, updated_at, last_seen_at) + VALUES (1, 200, 10, 1, 'Test issue', 'opened', 'author', 1000, 2000, 2000)", + [], + ) + .unwrap(); +} + +fn seed_second_project(conn: &Connection) { + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) + VALUES (2, 101, 'other/repo', 'https://gitlab.example.com/other', 1000, 2000)", + [], + ) + .unwrap(); +} + +fn seed_discussion_with_notes( + conn: &Connection, + issue_id: i64, + project_id: i64, + user_notes: usize, + system_notes: usize, +) { + let disc_id: i64 = conn + .query_row( + "SELECT COALESCE(MAX(id), 0) + 1 FROM discussions", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at) + VALUES (?1, ?2, ?3, ?4, 'Issue', 1000, 2000, 2000)", + rusqlite::params![disc_id, format!("disc-{}", disc_id), project_id, issue_id], + ) + .unwrap(); + for i in 0..user_notes { + conn.execute( + "INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position) + VALUES (?1, ?2, ?3, 'user1', 'comment', 1000, 2000, 2000, 0, ?4)", + rusqlite::params![1000 + disc_id * 100 + i as i64, disc_id, project_id, i as i64], + ) + .unwrap(); + } + for i in 0..system_notes { + conn.execute( + "INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position) + VALUES (?1, ?2, ?3, 'system', 'status changed', 1000, 2000, 2000, 1, ?4)", + rusqlite::params![2000 + disc_id * 100 + i as i64, disc_id, project_id, (user_notes + i) as i64], + ) + .unwrap(); + } +} + +// --- find_issue tests --- + +#[test] +fn test_find_issue_basic() { + let conn = setup_test_db(); + seed_issue(&conn); + let row = find_issue(&conn, 10, None).unwrap(); + assert_eq!(row.iid, 10); + assert_eq!(row.title, "Test issue"); + assert_eq!(row.state, "opened"); + assert_eq!(row.author_username, "author"); + assert_eq!(row.project_path, "group/repo"); +} + +#[test] +fn test_find_issue_with_project_filter() { + let conn = setup_test_db(); + seed_issue(&conn); + let row = find_issue(&conn, 10, Some("group/repo")).unwrap(); + assert_eq!(row.iid, 10); + assert_eq!(row.project_path, "group/repo"); +} + +#[test] +fn test_find_issue_not_found() { + let conn = setup_test_db(); + seed_issue(&conn); + let err = find_issue(&conn, 999, None).unwrap_err(); + assert!(matches!(err, LoreError::NotFound(_))); +} + +#[test] +fn test_find_issue_wrong_project_filter() { + let conn = setup_test_db(); + seed_issue(&conn); + seed_second_project(&conn); + // Issue 10 only exists in project 1, not project 2 + let err = find_issue(&conn, 10, Some("other/repo")).unwrap_err(); + assert!(matches!(err, LoreError::NotFound(_))); +} + +#[test] +fn test_find_issue_ambiguous_without_project() { + let conn = setup_test_db(); + seed_issue(&conn); // issue iid=10 in project 1 + seed_second_project(&conn); + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, + created_at, updated_at, last_seen_at) + VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)", + [], + ) + .unwrap(); + let err = find_issue(&conn, 10, None).unwrap_err(); + assert!(matches!(err, LoreError::Ambiguous(_))); +} + +#[test] +fn test_find_issue_ambiguous_resolved_with_project() { + let conn = setup_test_db(); + seed_issue(&conn); + seed_second_project(&conn); + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, + created_at, updated_at, last_seen_at) + VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)", + [], + ) + .unwrap(); + let row = find_issue(&conn, 10, Some("other/repo")).unwrap(); + assert_eq!(row.title, "Same iid different project"); +} + +#[test] +fn test_find_issue_user_notes_count_zero() { + let conn = setup_test_db(); + seed_issue(&conn); + let row = find_issue(&conn, 10, None).unwrap(); + assert_eq!(row.user_notes_count, 0); +} + +#[test] +fn test_find_issue_user_notes_count_excludes_system() { + let conn = setup_test_db(); + seed_issue(&conn); + // 2 user notes + 3 system notes = should count only 2 + seed_discussion_with_notes(&conn, 1, 1, 2, 3); + let row = find_issue(&conn, 10, None).unwrap(); + assert_eq!(row.user_notes_count, 2); +} + +#[test] +fn test_find_issue_user_notes_count_across_discussions() { + let conn = setup_test_db(); + seed_issue(&conn); + seed_discussion_with_notes(&conn, 1, 1, 3, 0); // 3 user notes + seed_discussion_with_notes(&conn, 1, 1, 1, 2); // 1 user note + 2 system + let row = find_issue(&conn, 10, None).unwrap(); + assert_eq!(row.user_notes_count, 4); +} + +#[test] +fn test_find_issue_notes_count_ignores_other_issues() { + let conn = setup_test_db(); + seed_issue(&conn); + // Add a second issue + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, + created_at, updated_at, last_seen_at) + VALUES (2, 201, 20, 1, 'Other issue', 'opened', 'author', 1000, 2000, 2000)", + [], + ) + .unwrap(); + // Notes on issue 2, not issue 1 + seed_discussion_with_notes(&conn, 2, 1, 5, 0); + let row = find_issue(&conn, 10, None).unwrap(); + assert_eq!(row.user_notes_count, 0); // Issue 10 has no notes +} + +#[test] +fn test_ansi256_from_rgb() { + // Moved to render.rs — keeping basic hex sanity check + let result = render::style_with_hex("test", Some("#ff0000")); + assert!(!result.is_empty()); +} + +#[test] +fn test_get_issue_assignees_empty() { + let conn = setup_test_db(); + seed_issue(&conn); + let result = get_issue_assignees(&conn, 1).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn test_get_issue_assignees_single() { + let conn = setup_test_db(); + seed_issue(&conn); + conn.execute( + "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'charlie')", + [], + ) + .unwrap(); + let result = get_issue_assignees(&conn, 1).unwrap(); + assert_eq!(result, vec!["charlie"]); +} + +#[test] +fn test_get_issue_assignees_multiple_sorted() { + let conn = setup_test_db(); + seed_issue(&conn); + conn.execute( + "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'bob')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')", + [], + ) + .unwrap(); + let result = get_issue_assignees(&conn, 1).unwrap(); + assert_eq!(result, vec!["alice", "bob"]); // alphabetical +} + +#[test] +fn test_get_closing_mrs_empty() { + let conn = setup_test_db(); + seed_issue(&conn); + let result = get_closing_mrs(&conn, 1).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn test_get_closing_mrs_single() { + let conn = setup_test_db(); + seed_issue(&conn); + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, + source_branch, target_branch, created_at, updated_at, last_seen_at) + VALUES (1, 300, 5, 1, 'Fix the bug', 'merged', 'dev', 'fix', 'main', 1000, 2000, 2000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, + target_entity_type, target_entity_id, reference_type, source_method, created_at) + VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", + [], + ) + .unwrap(); + let result = get_closing_mrs(&conn, 1).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].iid, 5); + assert_eq!(result[0].title, "Fix the bug"); + assert_eq!(result[0].state, "merged"); +} + +#[test] +fn test_get_closing_mrs_ignores_mentioned() { + let conn = setup_test_db(); + seed_issue(&conn); + // Add a 'mentioned' reference that should be ignored + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, + source_branch, target_branch, created_at, updated_at, last_seen_at) + VALUES (1, 300, 5, 1, 'Some MR', 'opened', 'dev', 'feat', 'main', 1000, 2000, 2000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, + target_entity_type, target_entity_id, reference_type, source_method, created_at) + VALUES (1, 'merge_request', 1, 'issue', 1, 'mentioned', 'note_parse', 3000)", + [], + ) + .unwrap(); + let result = get_closing_mrs(&conn, 1).unwrap(); + assert!(result.is_empty()); // 'mentioned' refs not included +} + +#[test] +fn test_get_closing_mrs_multiple_sorted() { + let conn = setup_test_db(); + seed_issue(&conn); + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, + source_branch, target_branch, created_at, updated_at, last_seen_at) + VALUES (1, 300, 8, 1, 'Second fix', 'opened', 'dev', 'fix2', 'main', 1000, 2000, 2000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, + source_branch, target_branch, created_at, updated_at, last_seen_at) + VALUES (2, 301, 5, 1, 'First fix', 'merged', 'dev', 'fix1', 'main', 1000, 2000, 2000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, + target_entity_type, target_entity_id, reference_type, source_method, created_at) + VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, + target_entity_type, target_entity_id, reference_type, source_method, created_at) + VALUES (1, 'merge_request', 2, 'issue', 1, 'closes', 'api', 3000)", + [], + ) + .unwrap(); + let result = get_closing_mrs(&conn, 1).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].iid, 5); // Lower iid first + assert_eq!(result[1].iid, 8); +} + +#[test] +fn wrap_text_single_line() { + assert_eq!(wrap_text("hello world", 80, " "), "hello world"); +} + +#[test] +fn wrap_text_multiple_lines() { + let result = wrap_text("one two three four five", 10, " "); + assert!(result.contains('\n')); +} + +#[test] +fn format_date_extracts_date_part() { + let ms = 1705276800000; + let date = format_date(ms); + assert!(date.starts_with("2024-01-15")); +} diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs deleted file mode 100644 index f6f1b20..0000000 --- a/src/cli/commands/sync.rs +++ /dev/null @@ -1,1201 +0,0 @@ -use crate::cli::render::{self, Icons, Theme, format_number}; -use serde::Serialize; -use std::time::Instant; -use tracing::Instrument; -use tracing::{debug, warn}; - -use crate::Config; -use crate::cli::progress::{format_stage_line, nested_progress, stage_spinner_v2}; -use crate::core::error::Result; -use crate::core::metrics::{MetricsLayer, StageTiming}; -use crate::core::shutdown::ShutdownSignal; - -use super::embed::run_embed; -use super::generate_docs::run_generate_docs; -use super::ingest::{ - DryRunPreview, IngestDisplay, ProjectStatusEnrichment, ProjectSummary, run_ingest, - run_ingest_dry_run, -}; -use super::sync_surgical::run_sync_surgical; - -#[derive(Debug, Default)] -pub struct SyncOptions { - pub full: bool, - pub force: bool, - pub no_embed: bool, - pub no_docs: bool, - pub no_events: bool, - pub robot_mode: bool, - pub dry_run: bool, - pub issue_iids: Vec, - pub mr_iids: Vec, - pub project: Option, - pub preflight_only: bool, -} - -impl SyncOptions { - pub const MAX_SURGICAL_TARGETS: usize = 100; - - pub fn is_surgical(&self) -> bool { - !self.issue_iids.is_empty() || !self.mr_iids.is_empty() - } -} - -#[derive(Debug, Default, Serialize)] -pub struct SurgicalIids { - pub issues: Vec, - pub merge_requests: Vec, -} - -#[derive(Debug, Serialize)] -pub struct EntitySyncResult { - pub entity_type: String, - pub iid: u64, - pub outcome: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub toctou_reason: Option, -} - -#[derive(Debug, Default, Serialize)] -pub struct SyncResult { - #[serde(skip)] - pub run_id: String, - pub issues_updated: usize, - pub mrs_updated: usize, - pub discussions_fetched: usize, - pub resource_events_fetched: usize, - pub resource_events_failed: usize, - pub mr_diffs_fetched: usize, - pub mr_diffs_failed: usize, - pub documents_regenerated: usize, - pub documents_errored: usize, - pub documents_embedded: usize, - pub embedding_failed: usize, - pub status_enrichment_errors: usize, - pub statuses_enriched: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub surgical_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub surgical_iids: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub entity_results: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub preflight_only: Option, - #[serde(skip)] - pub issue_projects: Vec, - #[serde(skip)] - pub mr_projects: Vec, -} - -/// Alias for [`Theme::color_icon`] to keep call sites concise. -fn color_icon(icon: &str, has_errors: bool) -> String { - Theme::color_icon(icon, has_errors) -} - -pub async fn run_sync( - config: &Config, - options: SyncOptions, - run_id: Option<&str>, - signal: &ShutdownSignal, -) -> Result { - // Surgical dispatch: if any IIDs specified, route to surgical pipeline - if options.is_surgical() { - return run_sync_surgical(config, options, run_id, signal).await; - } - - let generated_id; - let run_id = match run_id { - Some(id) => id, - None => { - generated_id = uuid::Uuid::new_v4().simple().to_string(); - &generated_id[..8] - } - }; - let span = tracing::info_span!("sync", %run_id); - - async move { - let mut result = SyncResult { - run_id: run_id.to_string(), - ..SyncResult::default() - }; - - // Handle dry_run mode - show preview without making any changes - if options.dry_run { - return run_sync_dry_run(config, &options).await; - } - - let ingest_display = if options.robot_mode { - IngestDisplay::silent() - } else { - IngestDisplay::progress_only() - }; - - // ── Stage: Issues ── - let stage_start = Instant::now(); - let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode); - debug!("Sync: ingesting issues"); - let issues_result = run_ingest( - config, - "issues", - None, - options.force, - options.full, - false, // dry_run - sync has its own dry_run handling - ingest_display, - Some(spinner.clone()), - signal, - ) - .await?; - result.issues_updated = issues_result.issues_upserted; - result.discussions_fetched += issues_result.discussions_fetched; - result.resource_events_fetched += issues_result.resource_events_fetched; - result.resource_events_failed += issues_result.resource_events_failed; - result.status_enrichment_errors += issues_result.status_enrichment_errors; - for sep in &issues_result.status_enrichment_projects { - result.statuses_enriched += sep.enriched; - } - result.issue_projects = issues_result.project_summaries; - let issues_elapsed = stage_start.elapsed(); - if !options.robot_mode { - let (status_summary, status_has_errors) = - summarize_status_enrichment(&issues_result.status_enrichment_projects); - let status_icon = color_icon( - if status_has_errors { - Icons::warning() - } else { - Icons::success() - }, - status_has_errors, - ); - let mut status_lines = vec![format_stage_line( - &status_icon, - "Status", - &status_summary, - issues_elapsed, - )]; - status_lines.extend(status_sub_rows(&issues_result.status_enrichment_projects)); - print_static_lines(&status_lines); - } - let mut issues_summary = format!( - "{} issues from {} {}", - format_number(result.issues_updated as i64), - issues_result.projects_synced, - if issues_result.projects_synced == 1 { "project" } else { "projects" } - ); - append_failures( - &mut issues_summary, - &[ - ("event failures", issues_result.resource_events_failed), - ("status errors", issues_result.status_enrichment_errors), - ], - ); - let issues_icon = color_icon( - if issues_result.resource_events_failed > 0 || issues_result.status_enrichment_errors > 0 - { - Icons::warning() - } else { - Icons::success() - }, - issues_result.resource_events_failed > 0 || issues_result.status_enrichment_errors > 0, - ); - if options.robot_mode { - emit_stage_line(&spinner, &issues_icon, "Issues", &issues_summary, issues_elapsed); - } else { - let sub_rows = issue_sub_rows(&result.issue_projects); - emit_stage_block( - &spinner, - &issues_icon, - "Issues", - &issues_summary, - issues_elapsed, - &sub_rows, - ); - } - - if signal.is_cancelled() { - debug!("Shutdown requested after issues stage, returning partial sync results"); - return Ok(result); - } - - // ── Stage: MRs ── - let stage_start = Instant::now(); - let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode); - debug!("Sync: ingesting merge requests"); - let mrs_result = run_ingest( - config, - "mrs", - None, - options.force, - options.full, - false, // dry_run - sync has its own dry_run handling - ingest_display, - Some(spinner.clone()), - signal, - ) - .await?; - result.mrs_updated = mrs_result.mrs_upserted; - result.discussions_fetched += mrs_result.discussions_fetched; - result.resource_events_fetched += mrs_result.resource_events_fetched; - result.resource_events_failed += mrs_result.resource_events_failed; - result.mr_diffs_fetched += mrs_result.mr_diffs_fetched; - result.mr_diffs_failed += mrs_result.mr_diffs_failed; - result.mr_projects = mrs_result.project_summaries; - let mrs_elapsed = stage_start.elapsed(); - let mut mrs_summary = format!( - "{} merge requests from {} {}", - format_number(result.mrs_updated as i64), - mrs_result.projects_synced, - if mrs_result.projects_synced == 1 { "project" } else { "projects" } - ); - append_failures( - &mut mrs_summary, - &[ - ("event failures", mrs_result.resource_events_failed), - ("diff failures", mrs_result.mr_diffs_failed), - ], - ); - let mrs_icon = color_icon( - if mrs_result.resource_events_failed > 0 || mrs_result.mr_diffs_failed > 0 { - Icons::warning() - } else { - Icons::success() - }, - mrs_result.resource_events_failed > 0 || mrs_result.mr_diffs_failed > 0, - ); - if options.robot_mode { - emit_stage_line(&spinner, &mrs_icon, "MRs", &mrs_summary, mrs_elapsed); - } else { - let sub_rows = mr_sub_rows(&result.mr_projects); - emit_stage_block(&spinner, &mrs_icon, "MRs", &mrs_summary, mrs_elapsed, &sub_rows); - } - - if signal.is_cancelled() { - debug!("Shutdown requested after MRs stage, returning partial sync results"); - return Ok(result); - } - - // ── Stage: Docs ── - if !options.no_docs { - let stage_start = Instant::now(); - let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode); - debug!("Sync: generating documents"); - - let docs_bar = nested_progress("Docs", 0, options.robot_mode); - let docs_bar_clone = docs_bar.clone(); - let docs_cb: Box = Box::new(move |processed, total| { - if total > 0 { - docs_bar_clone.set_length(total as u64); - docs_bar_clone.set_position(processed as u64); - } - }); - let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?; - result.documents_regenerated = docs_result.regenerated; - result.documents_errored = docs_result.errored; - docs_bar.finish_and_clear(); - let mut docs_summary = format!( - "{} documents generated", - format_number(result.documents_regenerated as i64), - ); - append_failures(&mut docs_summary, &[("errors", docs_result.errored)]); - let docs_icon = color_icon( - if docs_result.errored > 0 { - Icons::warning() - } else { - Icons::success() - }, - docs_result.errored > 0, - ); - emit_stage_line(&spinner, &docs_icon, "Docs", &docs_summary, stage_start.elapsed()); - } else { - debug!("Sync: skipping document generation (--no-docs)"); - } - - // ── Stage: Embed ── - if !options.no_embed { - let stage_start = Instant::now(); - let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode); - debug!("Sync: embedding documents"); - - let embed_bar = nested_progress("Embed", 0, options.robot_mode); - let embed_bar_clone = embed_bar.clone(); - let embed_cb: Box = Box::new(move |processed, total| { - if total > 0 { - embed_bar_clone.set_length(total as u64); - embed_bar_clone.set_position(processed as u64); - } - }); - match run_embed(config, options.full, false, Some(embed_cb), signal).await { - Ok(embed_result) => { - result.documents_embedded = embed_result.docs_embedded; - result.embedding_failed = embed_result.failed; - embed_bar.finish_and_clear(); - let mut embed_summary = format!( - "{} chunks embedded", - format_number(embed_result.chunks_embedded as i64), - ); - let mut tail_parts = Vec::new(); - if embed_result.failed > 0 { - tail_parts.push(format!("{} failed", embed_result.failed)); - } - if embed_result.skipped > 0 { - tail_parts.push(format!("{} skipped", embed_result.skipped)); - } - if !tail_parts.is_empty() { - embed_summary.push_str(&format!(" ({})", tail_parts.join(", "))); - } - let embed_icon = color_icon( - if embed_result.failed > 0 { - Icons::warning() - } else { - Icons::success() - }, - embed_result.failed > 0, - ); - emit_stage_line( - &spinner, - &embed_icon, - "Embed", - &embed_summary, - stage_start.elapsed(), - ); - } - Err(e) => { - embed_bar.finish_and_clear(); - let warn_summary = format!("skipped ({})", e); - let warn_icon = color_icon(Icons::warning(), true); - emit_stage_line( - &spinner, - &warn_icon, - "Embed", - &warn_summary, - stage_start.elapsed(), - ); - warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing"); - } - } - } else { - debug!("Sync: skipping embedding (--no-embed)"); - } - - debug!( - issues = result.issues_updated, - mrs = result.mrs_updated, - discussions = result.discussions_fetched, - resource_events = result.resource_events_fetched, - resource_events_failed = result.resource_events_failed, - mr_diffs = result.mr_diffs_fetched, - mr_diffs_failed = result.mr_diffs_failed, - docs = result.documents_regenerated, - embedded = result.documents_embedded, - "Sync pipeline complete" - ); - - Ok(result) - } - .instrument(span) - .await -} - -pub fn print_sync( - result: &SyncResult, - elapsed: std::time::Duration, - metrics: Option<&MetricsLayer>, - show_timings: bool, -) { - let has_data = result.issues_updated > 0 - || result.mrs_updated > 0 - || result.discussions_fetched > 0 - || result.resource_events_fetched > 0 - || result.mr_diffs_fetched > 0 - || result.documents_regenerated > 0 - || result.documents_embedded > 0 - || result.statuses_enriched > 0; - let has_failures = result.resource_events_failed > 0 - || result.mr_diffs_failed > 0 - || result.status_enrichment_errors > 0 - || result.documents_errored > 0 - || result.embedding_failed > 0; - - if !has_data && !has_failures { - println!( - "\n {} ({})\n", - Theme::dim().render("Already up to date"), - Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64())) - ); - } else { - let headline = if has_failures { - Theme::warning().bold().render("Sync completed with issues") - } else { - Theme::success().bold().render("Synced") - }; - println!( - "\n {} {} issues and {} MRs in {}", - headline, - Theme::info() - .bold() - .render(&result.issues_updated.to_string()), - Theme::info().bold().render(&result.mrs_updated.to_string()), - Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64())) - ); - - // Detail: supporting counts, compact middle-dot format, zero-suppressed - let mut details: Vec = Vec::new(); - if result.discussions_fetched > 0 { - details.push(format!( - "{} {}", - Theme::info().render(&result.discussions_fetched.to_string()), - Theme::dim().render("discussions") - )); - } - if result.resource_events_fetched > 0 { - details.push(format!( - "{} {}", - Theme::info().render(&result.resource_events_fetched.to_string()), - Theme::dim().render("events") - )); - } - if result.mr_diffs_fetched > 0 { - details.push(format!( - "{} {}", - Theme::info().render(&result.mr_diffs_fetched.to_string()), - Theme::dim().render("diffs") - )); - } - if result.statuses_enriched > 0 { - details.push(format!( - "{} {}", - Theme::info().render(&result.statuses_enriched.to_string()), - Theme::dim().render("statuses updated") - )); - } - if !details.is_empty() { - let sep = Theme::dim().render(" \u{b7} "); - println!(" {}", details.join(&sep)); - } - - // Documents: regeneration + embedding as a second detail line - let mut doc_parts: Vec = Vec::new(); - if result.documents_regenerated > 0 { - doc_parts.push(format!( - "{} {}", - Theme::info().render(&result.documents_regenerated.to_string()), - Theme::dim().render("docs regenerated") - )); - } - if result.documents_embedded > 0 { - doc_parts.push(format!( - "{} {}", - Theme::info().render(&result.documents_embedded.to_string()), - Theme::dim().render("embedded") - )); - } - if result.documents_errored > 0 { - doc_parts - .push(Theme::error().render(&format!("{} doc errors", result.documents_errored))); - } - if !doc_parts.is_empty() { - let sep = Theme::dim().render(" \u{b7} "); - println!(" {}", doc_parts.join(&sep)); - } - - // Errors: visually prominent, only if non-zero - let mut errors: Vec = Vec::new(); - if result.resource_events_failed > 0 { - errors.push(format!("{} event failures", result.resource_events_failed)); - } - if result.mr_diffs_failed > 0 { - errors.push(format!("{} diff failures", result.mr_diffs_failed)); - } - if result.status_enrichment_errors > 0 { - errors.push(format!("{} status errors", result.status_enrichment_errors)); - } - if result.embedding_failed > 0 { - errors.push(format!("{} embedding failures", result.embedding_failed)); - } - if !errors.is_empty() { - println!(" {}", Theme::error().render(&errors.join(" \u{b7} "))); - } - - println!(); - } - - if let Some(metrics) = metrics { - let stages = metrics.extract_timings(); - if should_print_timings(show_timings, &stages) { - print_timing_summary(&stages); - } - } -} - -fn issue_sub_rows(projects: &[ProjectSummary]) -> Vec { - projects - .iter() - .map(|p| { - let mut parts: Vec = Vec::new(); - parts.push(format!( - "{} {}", - p.items_upserted, - if p.items_upserted == 1 { - "issue" - } else { - "issues" - } - )); - if p.discussions_synced > 0 { - parts.push(format!("{} discussions", p.discussions_synced)); - } - if p.statuses_seen > 0 || p.statuses_enriched > 0 { - parts.push(format!("{} statuses updated", p.statuses_enriched)); - } - if p.events_fetched > 0 { - parts.push(format!("{} events", p.events_fetched)); - } - if p.status_errors > 0 { - parts.push(Theme::warning().render(&format!("{} status errors", p.status_errors))); - } - if p.events_failed > 0 { - parts.push(Theme::warning().render(&format!("{} event failures", p.events_failed))); - } - let sep = Theme::dim().render(" \u{b7} "); - let detail = parts.join(&sep); - let path = Theme::muted().render(&format!("{:<30}", p.path)); - format!(" {path} {detail}") - }) - .collect() -} - -fn status_sub_rows(projects: &[ProjectStatusEnrichment]) -> Vec { - projects - .iter() - .map(|p| { - let total_errors = p.partial_errors + usize::from(p.error.is_some()); - let mut parts: Vec = vec![format!("{} statuses updated", p.enriched)]; - if p.cleared > 0 { - parts.push(format!("{} cleared", p.cleared)); - } - if p.seen > 0 { - parts.push(format!("{} seen", p.seen)); - } - if total_errors > 0 { - parts.push(Theme::warning().render(&format!("{} errors", total_errors))); - } else if p.mode == "skipped" { - if let Some(reason) = &p.reason { - parts.push(Theme::dim().render(&format!("skipped ({reason})"))); - } else { - parts.push(Theme::dim().render("skipped")); - } - } - let sep = Theme::dim().render(" \u{b7} "); - let detail = parts.join(&sep); - let path = Theme::muted().render(&format!("{:<30}", p.path)); - format!(" {path} {detail}") - }) - .collect() -} - -fn mr_sub_rows(projects: &[ProjectSummary]) -> Vec { - projects - .iter() - .map(|p| { - let mut parts: Vec = Vec::new(); - parts.push(format!( - "{} {}", - p.items_upserted, - if p.items_upserted == 1 { "MR" } else { "MRs" } - )); - if p.discussions_synced > 0 { - parts.push(format!("{} discussions", p.discussions_synced)); - } - if p.mr_diffs_fetched > 0 { - parts.push(format!("{} diffs", p.mr_diffs_fetched)); - } - if p.events_fetched > 0 { - parts.push(format!("{} events", p.events_fetched)); - } - if p.mr_diffs_failed > 0 { - parts - .push(Theme::warning().render(&format!("{} diff failures", p.mr_diffs_failed))); - } - if p.events_failed > 0 { - parts.push(Theme::warning().render(&format!("{} event failures", p.events_failed))); - } - let sep = Theme::dim().render(" \u{b7} "); - let detail = parts.join(&sep); - let path = Theme::muted().render(&format!("{:<30}", p.path)); - format!(" {path} {detail}") - }) - .collect() -} - -fn emit_stage_line( - pb: &indicatif::ProgressBar, - icon: &str, - label: &str, - summary: &str, - elapsed: std::time::Duration, -) { - pb.finish_and_clear(); - print_static_lines(&[format_stage_line(icon, label, summary, elapsed)]); -} - -fn emit_stage_block( - pb: &indicatif::ProgressBar, - icon: &str, - label: &str, - summary: &str, - elapsed: std::time::Duration, - sub_rows: &[String], -) { - pb.finish_and_clear(); - let mut lines = Vec::with_capacity(1 + sub_rows.len()); - lines.push(format_stage_line(icon, label, summary, elapsed)); - lines.extend(sub_rows.iter().cloned()); - print_static_lines(&lines); -} - -fn print_static_lines(lines: &[String]) { - crate::cli::progress::multi().suspend(|| { - for line in lines { - println!("{line}"); - } - }); -} - -fn should_print_timings(show_timings: bool, stages: &[StageTiming]) -> bool { - show_timings && !stages.is_empty() -} - -fn append_failures(summary: &mut String, failures: &[(&str, usize)]) { - let rendered: Vec = failures - .iter() - .filter_map(|(label, count)| { - (*count > 0).then_some(Theme::warning().render(&format!("{count} {label}"))) - }) - .collect(); - if !rendered.is_empty() { - summary.push_str(&format!(" ({})", rendered.join(", "))); - } -} - -fn summarize_status_enrichment(projects: &[ProjectStatusEnrichment]) -> (String, bool) { - let statuses_enriched: usize = projects.iter().map(|p| p.enriched).sum(); - let statuses_seen: usize = projects.iter().map(|p| p.seen).sum(); - let statuses_cleared: usize = projects.iter().map(|p| p.cleared).sum(); - let status_errors: usize = projects - .iter() - .map(|p| p.partial_errors + usize::from(p.error.is_some())) - .sum(); - let skipped = projects.iter().filter(|p| p.mode == "skipped").count(); - - let mut parts = vec![format!( - "{} statuses updated", - format_number(statuses_enriched as i64) - )]; - if statuses_cleared > 0 { - parts.push(format!( - "{} cleared", - format_number(statuses_cleared as i64) - )); - } - if statuses_seen > 0 { - parts.push(format!("{} seen", format_number(statuses_seen as i64))); - } - if status_errors > 0 { - parts.push(format!("{} errors", format_number(status_errors as i64))); - } else if projects.is_empty() || skipped == projects.len() { - parts.push("skipped".to_string()); - } - - (parts.join(" \u{b7} "), status_errors > 0) -} - -fn section(title: &str) { - println!("{}", render::section_divider(title)); -} - -fn print_timing_summary(stages: &[StageTiming]) { - section("Timing"); - for stage in stages { - for sub in &stage.sub_stages { - print_stage_line(sub, 1); - } - } -} - -fn print_stage_line(stage: &StageTiming, depth: usize) { - let indent = " ".repeat(depth); - let name = if let Some(ref project) = stage.project { - format!("{} ({})", stage.name, project) - } else { - stage.name.clone() - }; - let pad_width = 30_usize.saturating_sub(indent.len() + name.len()); - let dots = Theme::dim().render(&".".repeat(pad_width.max(2))); - - let time_str = Theme::bold().render(&format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0)); - - let mut parts: Vec = Vec::new(); - if stage.items_processed > 0 { - parts.push(format!("{} items", stage.items_processed)); - } - if stage.errors > 0 { - parts.push(Theme::error().render(&format!("{} errors", stage.errors))); - } - if stage.rate_limit_hits > 0 { - parts.push(Theme::warning().render(&format!("{} rate limits", stage.rate_limit_hits))); - } - - if parts.is_empty() { - println!("{indent}{name} {dots} {time_str}"); - } else { - let suffix = parts.join(" \u{b7} "); - println!("{indent}{name} {dots} {time_str} ({suffix})"); - } - - for sub in &stage.sub_stages { - print_stage_line(sub, depth + 1); - } -} - -#[derive(Serialize)] -struct SyncJsonOutput<'a> { - ok: bool, - data: &'a SyncResult, - meta: SyncMeta, -} - -#[derive(Serialize)] -struct SyncMeta { - run_id: String, - elapsed_ms: u64, - #[serde(skip_serializing_if = "Vec::is_empty")] - stages: Vec, -} - -pub fn print_sync_json(result: &SyncResult, elapsed_ms: u64, metrics: Option<&MetricsLayer>) { - let stages = metrics.map_or_else(Vec::new, MetricsLayer::extract_timings); - let output = SyncJsonOutput { - ok: true, - data: result, - meta: SyncMeta { - run_id: result.run_id.clone(), - elapsed_ms, - stages, - }, - }; - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -#[derive(Debug, Default, Serialize)] -pub struct SyncDryRunResult { - pub issues_preview: DryRunPreview, - pub mrs_preview: DryRunPreview, - pub would_generate_docs: bool, - pub would_embed: bool, -} - -async fn run_sync_dry_run(config: &Config, options: &SyncOptions) -> Result { - // Get dry run previews for both issues and MRs - let issues_preview = run_ingest_dry_run(config, "issues", None, options.full)?; - let mrs_preview = run_ingest_dry_run(config, "mrs", None, options.full)?; - - let dry_result = SyncDryRunResult { - issues_preview, - mrs_preview, - would_generate_docs: !options.no_docs, - would_embed: !options.no_embed, - }; - - if options.robot_mode { - print_sync_dry_run_json(&dry_result); - } else { - print_sync_dry_run(&dry_result); - } - - // Return an empty SyncResult since this is just a preview - Ok(SyncResult::default()) -} - -pub fn print_sync_dry_run(result: &SyncDryRunResult) { - println!( - "\n {} {}", - Theme::info().bold().render("Dry run"), - Theme::dim().render("(no changes will be made)") - ); - - print_dry_run_entity("Issues", &result.issues_preview); - print_dry_run_entity("Merge Requests", &result.mrs_preview); - - // Pipeline stages - section("Pipeline"); - let mut stages: Vec = Vec::new(); - if result.would_generate_docs { - stages.push("generate-docs".to_string()); - } else { - stages.push(Theme::dim().render("generate-docs (skip)")); - } - if result.would_embed { - stages.push("embed".to_string()); - } else { - stages.push(Theme::dim().render("embed (skip)")); - } - println!(" {}", stages.join(" \u{b7} ")); -} - -fn print_dry_run_entity(label: &str, preview: &DryRunPreview) { - section(label); - let mode = if preview.sync_mode == "full" { - Theme::warning().render("full") - } else { - Theme::success().render("incremental") - }; - println!(" {} \u{b7} {} projects", mode, preview.projects.len()); - for project in &preview.projects { - let sync_status = if !project.has_cursor { - Theme::warning().render("initial sync") - } else { - Theme::success().render("incremental") - }; - if project.existing_count > 0 { - println!( - " {} \u{b7} {} \u{b7} {} existing", - &project.path, sync_status, project.existing_count - ); - } else { - println!(" {} \u{b7} {}", &project.path, sync_status); - } - } -} - -#[derive(Serialize)] -struct SyncDryRunJsonOutput { - ok: bool, - dry_run: bool, - data: SyncDryRunJsonData, -} - -#[derive(Serialize)] -struct SyncDryRunJsonData { - stages: Vec, -} - -#[derive(Serialize)] -struct SyncDryRunStage { - name: String, - would_run: bool, - #[serde(skip_serializing_if = "Option::is_none")] - preview: Option, -} - -pub fn print_sync_dry_run_json(result: &SyncDryRunResult) { - let output = SyncDryRunJsonOutput { - ok: true, - dry_run: true, - data: SyncDryRunJsonData { - stages: vec![ - SyncDryRunStage { - name: "ingest_issues".to_string(), - would_run: true, - preview: Some(result.issues_preview.clone()), - }, - SyncDryRunStage { - name: "ingest_mrs".to_string(), - would_run: true, - preview: Some(result.mrs_preview.clone()), - }, - SyncDryRunStage { - name: "generate_docs".to_string(), - would_run: result.would_generate_docs, - preview: None, - }, - SyncDryRunStage { - name: "embed".to_string(), - would_run: result.would_embed, - preview: None, - }, - ], - }, - }; - - match serde_json::to_string(&output) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("Error serializing to JSON: {e}"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn default_options() -> SyncOptions { - SyncOptions { - full: false, - force: false, - no_embed: false, - no_docs: false, - no_events: false, - robot_mode: false, - dry_run: false, - issue_iids: vec![], - mr_iids: vec![], - project: None, - preflight_only: false, - } - } - - #[test] - fn append_failures_skips_zeroes() { - let mut summary = "base".to_string(); - append_failures(&mut summary, &[("errors", 0), ("failures", 0)]); - assert_eq!(summary, "base"); - } - - #[test] - fn append_failures_renders_non_zero_counts() { - let mut summary = "base".to_string(); - append_failures(&mut summary, &[("errors", 2), ("failures", 1)]); - assert!(summary.contains("base")); - assert!(summary.contains("2 errors")); - assert!(summary.contains("1 failures")); - } - - #[test] - fn summarize_status_enrichment_reports_skipped_when_all_skipped() { - let projects = vec![ProjectStatusEnrichment { - path: "vs/typescript-code".to_string(), - mode: "skipped".to_string(), - reason: None, - seen: 0, - enriched: 0, - cleared: 0, - without_widget: 0, - partial_errors: 0, - first_partial_error: None, - error: None, - }]; - - let (summary, has_errors) = summarize_status_enrichment(&projects); - assert!(summary.contains("0 statuses updated")); - assert!(summary.contains("skipped")); - assert!(!has_errors); - } - - #[test] - fn summarize_status_enrichment_reports_errors() { - let projects = vec![ProjectStatusEnrichment { - path: "vs/typescript-code".to_string(), - mode: "fetched".to_string(), - reason: None, - seen: 3, - enriched: 1, - cleared: 1, - without_widget: 0, - partial_errors: 2, - first_partial_error: None, - error: Some("boom".to_string()), - }]; - - let (summary, has_errors) = summarize_status_enrichment(&projects); - assert!(summary.contains("1 statuses updated")); - assert!(summary.contains("1 cleared")); - assert!(summary.contains("3 seen")); - assert!(summary.contains("3 errors")); - assert!(has_errors); - } - - #[test] - fn should_print_timings_only_when_enabled_and_non_empty() { - let stages = vec![StageTiming { - name: "x".to_string(), - elapsed_ms: 10, - items_processed: 0, - items_skipped: 0, - errors: 0, - rate_limit_hits: 0, - retries: 0, - project: None, - sub_stages: vec![], - }]; - - assert!(should_print_timings(true, &stages)); - assert!(!should_print_timings(false, &stages)); - assert!(!should_print_timings(true, &[])); - } - - #[test] - fn issue_sub_rows_include_project_and_statuses() { - let rows = issue_sub_rows(&[ProjectSummary { - path: "vs/typescript-code".to_string(), - items_upserted: 2, - discussions_synced: 0, - events_fetched: 0, - events_failed: 0, - statuses_enriched: 1, - statuses_seen: 5, - status_errors: 0, - mr_diffs_fetched: 0, - mr_diffs_failed: 0, - }]); - - assert_eq!(rows.len(), 1); - assert!(rows[0].contains("vs/typescript-code")); - assert!(rows[0].contains("2 issues")); - assert!(rows[0].contains("1 statuses updated")); - } - - #[test] - fn mr_sub_rows_include_project_and_diff_failures() { - let rows = mr_sub_rows(&[ProjectSummary { - path: "vs/python-code".to_string(), - items_upserted: 3, - discussions_synced: 0, - events_fetched: 0, - events_failed: 0, - statuses_enriched: 0, - statuses_seen: 0, - status_errors: 0, - mr_diffs_fetched: 4, - mr_diffs_failed: 1, - }]); - - assert_eq!(rows.len(), 1); - assert!(rows[0].contains("vs/python-code")); - assert!(rows[0].contains("3 MRs")); - assert!(rows[0].contains("4 diffs")); - assert!(rows[0].contains("1 diff failures")); - } - - #[test] - fn status_sub_rows_include_project_and_skip_reason() { - let rows = status_sub_rows(&[ProjectStatusEnrichment { - path: "vs/python-code".to_string(), - mode: "skipped".to_string(), - reason: Some("disabled".to_string()), - seen: 0, - enriched: 0, - cleared: 0, - without_widget: 0, - partial_errors: 0, - first_partial_error: None, - error: None, - }]); - - assert_eq!(rows.len(), 1); - assert!(rows[0].contains("vs/python-code")); - assert!(rows[0].contains("0 statuses updated")); - assert!(rows[0].contains("skipped (disabled)")); - } - - #[test] - fn is_surgical_with_issues() { - let opts = SyncOptions { - issue_iids: vec![1], - ..default_options() - }; - assert!(opts.is_surgical()); - } - - #[test] - fn is_surgical_with_mrs() { - let opts = SyncOptions { - mr_iids: vec![10], - ..default_options() - }; - assert!(opts.is_surgical()); - } - - #[test] - fn is_surgical_empty() { - let opts = default_options(); - assert!(!opts.is_surgical()); - } - - #[test] - fn max_surgical_targets_is_100() { - assert_eq!(SyncOptions::MAX_SURGICAL_TARGETS, 100); - } - - #[test] - fn sync_result_default_omits_surgical_fields() { - let result = SyncResult::default(); - let json = serde_json::to_value(&result).unwrap(); - assert!(json.get("surgical_mode").is_none()); - assert!(json.get("surgical_iids").is_none()); - assert!(json.get("entity_results").is_none()); - assert!(json.get("preflight_only").is_none()); - } - - #[test] - fn sync_result_with_surgical_fields_serializes_correctly() { - let result = SyncResult { - surgical_mode: Some(true), - surgical_iids: Some(SurgicalIids { - issues: vec![7, 42], - merge_requests: vec![10], - }), - entity_results: Some(vec![ - EntitySyncResult { - entity_type: "issue".to_string(), - iid: 7, - outcome: "synced".to_string(), - error: None, - toctou_reason: None, - }, - EntitySyncResult { - entity_type: "issue".to_string(), - iid: 42, - outcome: "skipped_toctou".to_string(), - error: None, - toctou_reason: Some("updated_at changed".to_string()), - }, - ]), - preflight_only: Some(false), - ..SyncResult::default() - }; - let json = serde_json::to_value(&result).unwrap(); - assert_eq!(json["surgical_mode"], true); - assert_eq!(json["surgical_iids"]["issues"], serde_json::json!([7, 42])); - assert_eq!(json["entity_results"].as_array().unwrap().len(), 2); - assert_eq!(json["entity_results"][1]["outcome"], "skipped_toctou"); - assert_eq!(json["preflight_only"], false); - } - - #[test] - fn entity_sync_result_omits_none_fields() { - let entity = EntitySyncResult { - entity_type: "merge_request".to_string(), - iid: 10, - outcome: "synced".to_string(), - error: None, - toctou_reason: None, - }; - let json = serde_json::to_value(&entity).unwrap(); - assert!(json.get("error").is_none()); - assert!(json.get("toctou_reason").is_none()); - assert!(json.get("entity_type").is_some()); - } - - #[test] - fn is_surgical_with_both_issues_and_mrs() { - let opts = SyncOptions { - issue_iids: vec![1, 2], - mr_iids: vec![10], - ..default_options() - }; - assert!(opts.is_surgical()); - } - - #[test] - fn is_not_surgical_with_only_project() { - let opts = SyncOptions { - project: Some("group/repo".to_string()), - ..default_options() - }; - assert!(!opts.is_surgical()); - } -} diff --git a/src/cli/commands/sync/mod.rs b/src/cli/commands/sync/mod.rs new file mode 100644 index 0000000..a29859d --- /dev/null +++ b/src/cli/commands/sync/mod.rs @@ -0,0 +1,24 @@ +pub mod surgical; +pub use surgical::run_sync_surgical; + +use crate::cli::render::{self, Icons, Theme, format_number}; +use serde::Serialize; +use std::time::Instant; +use tracing::Instrument; +use tracing::{debug, warn}; + +use crate::Config; +use crate::cli::progress::{format_stage_line, nested_progress, stage_spinner_v2}; +use crate::core::error::Result; +use crate::core::metrics::{MetricsLayer, StageTiming}; +use crate::core::shutdown::ShutdownSignal; + +use super::embed::run_embed; +use super::generate_docs::run_generate_docs; +use super::ingest::{ + DryRunPreview, IngestDisplay, ProjectStatusEnrichment, ProjectSummary, run_ingest, + run_ingest_dry_run, +}; + +include!("run.rs"); +include!("render.rs"); diff --git a/src/cli/commands/sync/render.rs b/src/cli/commands/sync/render.rs new file mode 100644 index 0000000..2db2a60 --- /dev/null +++ b/src/cli/commands/sync/render.rs @@ -0,0 +1,533 @@ +pub fn print_sync( + result: &SyncResult, + elapsed: std::time::Duration, + metrics: Option<&MetricsLayer>, + show_timings: bool, +) { + let has_data = result.issues_updated > 0 + || result.mrs_updated > 0 + || result.discussions_fetched > 0 + || result.resource_events_fetched > 0 + || result.mr_diffs_fetched > 0 + || result.documents_regenerated > 0 + || result.documents_embedded > 0 + || result.statuses_enriched > 0; + let has_failures = result.resource_events_failed > 0 + || result.mr_diffs_failed > 0 + || result.status_enrichment_errors > 0 + || result.documents_errored > 0 + || result.embedding_failed > 0; + + if !has_data && !has_failures { + println!( + "\n {} ({})\n", + Theme::dim().render("Already up to date"), + Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64())) + ); + } else { + let headline = if has_failures { + Theme::warning().bold().render("Sync completed with issues") + } else { + Theme::success().bold().render("Synced") + }; + println!( + "\n {} {} issues and {} MRs in {}", + headline, + Theme::info() + .bold() + .render(&result.issues_updated.to_string()), + Theme::info().bold().render(&result.mrs_updated.to_string()), + Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64())) + ); + + // Detail: supporting counts, compact middle-dot format, zero-suppressed + let mut details: Vec = Vec::new(); + if result.discussions_fetched > 0 { + details.push(format!( + "{} {}", + Theme::info().render(&result.discussions_fetched.to_string()), + Theme::dim().render("discussions") + )); + } + if result.resource_events_fetched > 0 { + details.push(format!( + "{} {}", + Theme::info().render(&result.resource_events_fetched.to_string()), + Theme::dim().render("events") + )); + } + if result.mr_diffs_fetched > 0 { + details.push(format!( + "{} {}", + Theme::info().render(&result.mr_diffs_fetched.to_string()), + Theme::dim().render("diffs") + )); + } + if result.statuses_enriched > 0 { + details.push(format!( + "{} {}", + Theme::info().render(&result.statuses_enriched.to_string()), + Theme::dim().render("statuses updated") + )); + } + if !details.is_empty() { + let sep = Theme::dim().render(" \u{b7} "); + println!(" {}", details.join(&sep)); + } + + // Documents: regeneration + embedding as a second detail line + let mut doc_parts: Vec = Vec::new(); + if result.documents_regenerated > 0 { + doc_parts.push(format!( + "{} {}", + Theme::info().render(&result.documents_regenerated.to_string()), + Theme::dim().render("docs regenerated") + )); + } + if result.documents_embedded > 0 { + doc_parts.push(format!( + "{} {}", + Theme::info().render(&result.documents_embedded.to_string()), + Theme::dim().render("embedded") + )); + } + if result.documents_errored > 0 { + doc_parts + .push(Theme::error().render(&format!("{} doc errors", result.documents_errored))); + } + if !doc_parts.is_empty() { + let sep = Theme::dim().render(" \u{b7} "); + println!(" {}", doc_parts.join(&sep)); + } + + // Errors: visually prominent, only if non-zero + let mut errors: Vec = Vec::new(); + if result.resource_events_failed > 0 { + errors.push(format!("{} event failures", result.resource_events_failed)); + } + if result.mr_diffs_failed > 0 { + errors.push(format!("{} diff failures", result.mr_diffs_failed)); + } + if result.status_enrichment_errors > 0 { + errors.push(format!("{} status errors", result.status_enrichment_errors)); + } + if result.embedding_failed > 0 { + errors.push(format!("{} embedding failures", result.embedding_failed)); + } + if !errors.is_empty() { + println!(" {}", Theme::error().render(&errors.join(" \u{b7} "))); + } + + println!(); + } + + if let Some(metrics) = metrics { + let stages = metrics.extract_timings(); + if should_print_timings(show_timings, &stages) { + print_timing_summary(&stages); + } + } +} + +fn issue_sub_rows(projects: &[ProjectSummary]) -> Vec { + projects + .iter() + .map(|p| { + let mut parts: Vec = Vec::new(); + parts.push(format!( + "{} {}", + p.items_upserted, + if p.items_upserted == 1 { + "issue" + } else { + "issues" + } + )); + if p.discussions_synced > 0 { + parts.push(format!("{} discussions", p.discussions_synced)); + } + if p.statuses_seen > 0 || p.statuses_enriched > 0 { + parts.push(format!("{} statuses updated", p.statuses_enriched)); + } + if p.events_fetched > 0 { + parts.push(format!("{} events", p.events_fetched)); + } + if p.status_errors > 0 { + parts.push(Theme::warning().render(&format!("{} status errors", p.status_errors))); + } + if p.events_failed > 0 { + parts.push(Theme::warning().render(&format!("{} event failures", p.events_failed))); + } + let sep = Theme::dim().render(" \u{b7} "); + let detail = parts.join(&sep); + let path = Theme::muted().render(&format!("{:<30}", p.path)); + format!(" {path} {detail}") + }) + .collect() +} + +fn status_sub_rows(projects: &[ProjectStatusEnrichment]) -> Vec { + projects + .iter() + .map(|p| { + let total_errors = p.partial_errors + usize::from(p.error.is_some()); + let mut parts: Vec = vec![format!("{} statuses updated", p.enriched)]; + if p.cleared > 0 { + parts.push(format!("{} cleared", p.cleared)); + } + if p.seen > 0 { + parts.push(format!("{} seen", p.seen)); + } + if total_errors > 0 { + parts.push(Theme::warning().render(&format!("{} errors", total_errors))); + } else if p.mode == "skipped" { + if let Some(reason) = &p.reason { + parts.push(Theme::dim().render(&format!("skipped ({reason})"))); + } else { + parts.push(Theme::dim().render("skipped")); + } + } + let sep = Theme::dim().render(" \u{b7} "); + let detail = parts.join(&sep); + let path = Theme::muted().render(&format!("{:<30}", p.path)); + format!(" {path} {detail}") + }) + .collect() +} + +fn mr_sub_rows(projects: &[ProjectSummary]) -> Vec { + projects + .iter() + .map(|p| { + let mut parts: Vec = Vec::new(); + parts.push(format!( + "{} {}", + p.items_upserted, + if p.items_upserted == 1 { "MR" } else { "MRs" } + )); + if p.discussions_synced > 0 { + parts.push(format!("{} discussions", p.discussions_synced)); + } + if p.mr_diffs_fetched > 0 { + parts.push(format!("{} diffs", p.mr_diffs_fetched)); + } + if p.events_fetched > 0 { + parts.push(format!("{} events", p.events_fetched)); + } + if p.mr_diffs_failed > 0 { + parts + .push(Theme::warning().render(&format!("{} diff failures", p.mr_diffs_failed))); + } + if p.events_failed > 0 { + parts.push(Theme::warning().render(&format!("{} event failures", p.events_failed))); + } + let sep = Theme::dim().render(" \u{b7} "); + let detail = parts.join(&sep); + let path = Theme::muted().render(&format!("{:<30}", p.path)); + format!(" {path} {detail}") + }) + .collect() +} + +fn emit_stage_line( + pb: &indicatif::ProgressBar, + icon: &str, + label: &str, + summary: &str, + elapsed: std::time::Duration, +) { + pb.finish_and_clear(); + print_static_lines(&[format_stage_line(icon, label, summary, elapsed)]); +} + +fn emit_stage_block( + pb: &indicatif::ProgressBar, + icon: &str, + label: &str, + summary: &str, + elapsed: std::time::Duration, + sub_rows: &[String], +) { + pb.finish_and_clear(); + let mut lines = Vec::with_capacity(1 + sub_rows.len()); + lines.push(format_stage_line(icon, label, summary, elapsed)); + lines.extend(sub_rows.iter().cloned()); + print_static_lines(&lines); +} + +fn print_static_lines(lines: &[String]) { + crate::cli::progress::multi().suspend(|| { + for line in lines { + println!("{line}"); + } + }); +} + +fn should_print_timings(show_timings: bool, stages: &[StageTiming]) -> bool { + show_timings && !stages.is_empty() +} + +fn append_failures(summary: &mut String, failures: &[(&str, usize)]) { + let rendered: Vec = failures + .iter() + .filter_map(|(label, count)| { + (*count > 0).then_some(Theme::warning().render(&format!("{count} {label}"))) + }) + .collect(); + if !rendered.is_empty() { + summary.push_str(&format!(" ({})", rendered.join(", "))); + } +} + +fn summarize_status_enrichment(projects: &[ProjectStatusEnrichment]) -> (String, bool) { + let statuses_enriched: usize = projects.iter().map(|p| p.enriched).sum(); + let statuses_seen: usize = projects.iter().map(|p| p.seen).sum(); + let statuses_cleared: usize = projects.iter().map(|p| p.cleared).sum(); + let status_errors: usize = projects + .iter() + .map(|p| p.partial_errors + usize::from(p.error.is_some())) + .sum(); + let skipped = projects.iter().filter(|p| p.mode == "skipped").count(); + + let mut parts = vec![format!( + "{} statuses updated", + format_number(statuses_enriched as i64) + )]; + if statuses_cleared > 0 { + parts.push(format!( + "{} cleared", + format_number(statuses_cleared as i64) + )); + } + if statuses_seen > 0 { + parts.push(format!("{} seen", format_number(statuses_seen as i64))); + } + if status_errors > 0 { + parts.push(format!("{} errors", format_number(status_errors as i64))); + } else if projects.is_empty() || skipped == projects.len() { + parts.push("skipped".to_string()); + } + + (parts.join(" \u{b7} "), status_errors > 0) +} + +fn section(title: &str) { + println!("{}", render::section_divider(title)); +} + +fn print_timing_summary(stages: &[StageTiming]) { + section("Timing"); + for stage in stages { + for sub in &stage.sub_stages { + print_stage_line(sub, 1); + } + } +} + +fn print_stage_line(stage: &StageTiming, depth: usize) { + let indent = " ".repeat(depth); + let name = if let Some(ref project) = stage.project { + format!("{} ({})", stage.name, project) + } else { + stage.name.clone() + }; + let pad_width = 30_usize.saturating_sub(indent.len() + name.len()); + let dots = Theme::dim().render(&".".repeat(pad_width.max(2))); + + let time_str = Theme::bold().render(&format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0)); + + let mut parts: Vec = Vec::new(); + if stage.items_processed > 0 { + parts.push(format!("{} items", stage.items_processed)); + } + if stage.errors > 0 { + parts.push(Theme::error().render(&format!("{} errors", stage.errors))); + } + if stage.rate_limit_hits > 0 { + parts.push(Theme::warning().render(&format!("{} rate limits", stage.rate_limit_hits))); + } + + if parts.is_empty() { + println!("{indent}{name} {dots} {time_str}"); + } else { + let suffix = parts.join(" \u{b7} "); + println!("{indent}{name} {dots} {time_str} ({suffix})"); + } + + for sub in &stage.sub_stages { + print_stage_line(sub, depth + 1); + } +} + +#[derive(Serialize)] +struct SyncJsonOutput<'a> { + ok: bool, + data: &'a SyncResult, + meta: SyncMeta, +} + +#[derive(Serialize)] +struct SyncMeta { + run_id: String, + elapsed_ms: u64, + #[serde(skip_serializing_if = "Vec::is_empty")] + stages: Vec, +} + +pub fn print_sync_json(result: &SyncResult, elapsed_ms: u64, metrics: Option<&MetricsLayer>) { + let stages = metrics.map_or_else(Vec::new, MetricsLayer::extract_timings); + let output = SyncJsonOutput { + ok: true, + data: result, + meta: SyncMeta { + run_id: result.run_id.clone(), + elapsed_ms, + stages, + }, + }; + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +#[derive(Debug, Default, Serialize)] +pub struct SyncDryRunResult { + pub issues_preview: DryRunPreview, + pub mrs_preview: DryRunPreview, + pub would_generate_docs: bool, + pub would_embed: bool, +} + +async fn run_sync_dry_run(config: &Config, options: &SyncOptions) -> Result { + // Get dry run previews for both issues and MRs + let issues_preview = run_ingest_dry_run(config, "issues", None, options.full)?; + let mrs_preview = run_ingest_dry_run(config, "mrs", None, options.full)?; + + let dry_result = SyncDryRunResult { + issues_preview, + mrs_preview, + would_generate_docs: !options.no_docs, + would_embed: !options.no_embed, + }; + + if options.robot_mode { + print_sync_dry_run_json(&dry_result); + } else { + print_sync_dry_run(&dry_result); + } + + // Return an empty SyncResult since this is just a preview + Ok(SyncResult::default()) +} + +pub fn print_sync_dry_run(result: &SyncDryRunResult) { + println!( + "\n {} {}", + Theme::info().bold().render("Dry run"), + Theme::dim().render("(no changes will be made)") + ); + + print_dry_run_entity("Issues", &result.issues_preview); + print_dry_run_entity("Merge Requests", &result.mrs_preview); + + // Pipeline stages + section("Pipeline"); + let mut stages: Vec = Vec::new(); + if result.would_generate_docs { + stages.push("generate-docs".to_string()); + } else { + stages.push(Theme::dim().render("generate-docs (skip)")); + } + if result.would_embed { + stages.push("embed".to_string()); + } else { + stages.push(Theme::dim().render("embed (skip)")); + } + println!(" {}", stages.join(" \u{b7} ")); +} + +fn print_dry_run_entity(label: &str, preview: &DryRunPreview) { + section(label); + let mode = if preview.sync_mode == "full" { + Theme::warning().render("full") + } else { + Theme::success().render("incremental") + }; + println!(" {} \u{b7} {} projects", mode, preview.projects.len()); + for project in &preview.projects { + let sync_status = if !project.has_cursor { + Theme::warning().render("initial sync") + } else { + Theme::success().render("incremental") + }; + if project.existing_count > 0 { + println!( + " {} \u{b7} {} \u{b7} {} existing", + &project.path, sync_status, project.existing_count + ); + } else { + println!(" {} \u{b7} {}", &project.path, sync_status); + } + } +} + +#[derive(Serialize)] +struct SyncDryRunJsonOutput { + ok: bool, + dry_run: bool, + data: SyncDryRunJsonData, +} + +#[derive(Serialize)] +struct SyncDryRunJsonData { + stages: Vec, +} + +#[derive(Serialize)] +struct SyncDryRunStage { + name: String, + would_run: bool, + #[serde(skip_serializing_if = "Option::is_none")] + preview: Option, +} + +pub fn print_sync_dry_run_json(result: &SyncDryRunResult) { + let output = SyncDryRunJsonOutput { + ok: true, + dry_run: true, + data: SyncDryRunJsonData { + stages: vec![ + SyncDryRunStage { + name: "ingest_issues".to_string(), + would_run: true, + preview: Some(result.issues_preview.clone()), + }, + SyncDryRunStage { + name: "ingest_mrs".to_string(), + would_run: true, + preview: Some(result.mrs_preview.clone()), + }, + SyncDryRunStage { + name: "generate_docs".to_string(), + would_run: result.would_generate_docs, + preview: None, + }, + SyncDryRunStage { + name: "embed".to_string(), + would_run: result.would_embed, + preview: None, + }, + ], + }, + }; + + match serde_json::to_string(&output) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +#[cfg(test)] +#[path = "sync_tests.rs"] +mod tests; diff --git a/src/cli/commands/sync/run.rs b/src/cli/commands/sync/run.rs new file mode 100644 index 0000000..75902a6 --- /dev/null +++ b/src/cli/commands/sync/run.rs @@ -0,0 +1,380 @@ +#[derive(Debug, Default)] +pub struct SyncOptions { + pub full: bool, + pub force: bool, + pub no_embed: bool, + pub no_docs: bool, + pub no_events: bool, + pub robot_mode: bool, + pub dry_run: bool, + pub issue_iids: Vec, + pub mr_iids: Vec, + pub project: Option, + pub preflight_only: bool, +} + +impl SyncOptions { + pub const MAX_SURGICAL_TARGETS: usize = 100; + + pub fn is_surgical(&self) -> bool { + !self.issue_iids.is_empty() || !self.mr_iids.is_empty() + } +} + +#[derive(Debug, Default, Serialize)] +pub struct SurgicalIids { + pub issues: Vec, + pub merge_requests: Vec, +} + +#[derive(Debug, Serialize)] +pub struct EntitySyncResult { + pub entity_type: String, + pub iid: u64, + pub outcome: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toctou_reason: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct SyncResult { + #[serde(skip)] + pub run_id: String, + pub issues_updated: usize, + pub mrs_updated: usize, + pub discussions_fetched: usize, + pub resource_events_fetched: usize, + pub resource_events_failed: usize, + pub mr_diffs_fetched: usize, + pub mr_diffs_failed: usize, + pub documents_regenerated: usize, + pub documents_errored: usize, + pub documents_embedded: usize, + pub embedding_failed: usize, + pub status_enrichment_errors: usize, + pub statuses_enriched: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub surgical_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub surgical_iids: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub entity_results: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preflight_only: Option, + #[serde(skip)] + pub issue_projects: Vec, + #[serde(skip)] + pub mr_projects: Vec, +} + +/// Alias for [`Theme::color_icon`] to keep call sites concise. +fn color_icon(icon: &str, has_errors: bool) -> String { + Theme::color_icon(icon, has_errors) +} + +pub async fn run_sync( + config: &Config, + options: SyncOptions, + run_id: Option<&str>, + signal: &ShutdownSignal, +) -> Result { + // Surgical dispatch: if any IIDs specified, route to surgical pipeline + if options.is_surgical() { + return run_sync_surgical(config, options, run_id, signal).await; + } + + let generated_id; + let run_id = match run_id { + Some(id) => id, + None => { + generated_id = uuid::Uuid::new_v4().simple().to_string(); + &generated_id[..8] + } + }; + let span = tracing::info_span!("sync", %run_id); + + async move { + let mut result = SyncResult { + run_id: run_id.to_string(), + ..SyncResult::default() + }; + + // Handle dry_run mode - show preview without making any changes + if options.dry_run { + return run_sync_dry_run(config, &options).await; + } + + let ingest_display = if options.robot_mode { + IngestDisplay::silent() + } else { + IngestDisplay::progress_only() + }; + + // ── Stage: Issues ── + let stage_start = Instant::now(); + let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode); + debug!("Sync: ingesting issues"); + let issues_result = run_ingest( + config, + "issues", + None, + options.force, + options.full, + false, // dry_run - sync has its own dry_run handling + ingest_display, + Some(spinner.clone()), + signal, + ) + .await?; + result.issues_updated = issues_result.issues_upserted; + result.discussions_fetched += issues_result.discussions_fetched; + result.resource_events_fetched += issues_result.resource_events_fetched; + result.resource_events_failed += issues_result.resource_events_failed; + result.status_enrichment_errors += issues_result.status_enrichment_errors; + for sep in &issues_result.status_enrichment_projects { + result.statuses_enriched += sep.enriched; + } + result.issue_projects = issues_result.project_summaries; + let issues_elapsed = stage_start.elapsed(); + if !options.robot_mode { + let (status_summary, status_has_errors) = + summarize_status_enrichment(&issues_result.status_enrichment_projects); + let status_icon = color_icon( + if status_has_errors { + Icons::warning() + } else { + Icons::success() + }, + status_has_errors, + ); + let mut status_lines = vec![format_stage_line( + &status_icon, + "Status", + &status_summary, + issues_elapsed, + )]; + status_lines.extend(status_sub_rows(&issues_result.status_enrichment_projects)); + print_static_lines(&status_lines); + } + let mut issues_summary = format!( + "{} issues from {} {}", + format_number(result.issues_updated as i64), + issues_result.projects_synced, + if issues_result.projects_synced == 1 { "project" } else { "projects" } + ); + append_failures( + &mut issues_summary, + &[ + ("event failures", issues_result.resource_events_failed), + ("status errors", issues_result.status_enrichment_errors), + ], + ); + let issues_icon = color_icon( + if issues_result.resource_events_failed > 0 || issues_result.status_enrichment_errors > 0 + { + Icons::warning() + } else { + Icons::success() + }, + issues_result.resource_events_failed > 0 || issues_result.status_enrichment_errors > 0, + ); + if options.robot_mode { + emit_stage_line(&spinner, &issues_icon, "Issues", &issues_summary, issues_elapsed); + } else { + let sub_rows = issue_sub_rows(&result.issue_projects); + emit_stage_block( + &spinner, + &issues_icon, + "Issues", + &issues_summary, + issues_elapsed, + &sub_rows, + ); + } + + if signal.is_cancelled() { + debug!("Shutdown requested after issues stage, returning partial sync results"); + return Ok(result); + } + + // ── Stage: MRs ── + let stage_start = Instant::now(); + let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode); + debug!("Sync: ingesting merge requests"); + let mrs_result = run_ingest( + config, + "mrs", + None, + options.force, + options.full, + false, // dry_run - sync has its own dry_run handling + ingest_display, + Some(spinner.clone()), + signal, + ) + .await?; + result.mrs_updated = mrs_result.mrs_upserted; + result.discussions_fetched += mrs_result.discussions_fetched; + result.resource_events_fetched += mrs_result.resource_events_fetched; + result.resource_events_failed += mrs_result.resource_events_failed; + result.mr_diffs_fetched += mrs_result.mr_diffs_fetched; + result.mr_diffs_failed += mrs_result.mr_diffs_failed; + result.mr_projects = mrs_result.project_summaries; + let mrs_elapsed = stage_start.elapsed(); + let mut mrs_summary = format!( + "{} merge requests from {} {}", + format_number(result.mrs_updated as i64), + mrs_result.projects_synced, + if mrs_result.projects_synced == 1 { "project" } else { "projects" } + ); + append_failures( + &mut mrs_summary, + &[ + ("event failures", mrs_result.resource_events_failed), + ("diff failures", mrs_result.mr_diffs_failed), + ], + ); + let mrs_icon = color_icon( + if mrs_result.resource_events_failed > 0 || mrs_result.mr_diffs_failed > 0 { + Icons::warning() + } else { + Icons::success() + }, + mrs_result.resource_events_failed > 0 || mrs_result.mr_diffs_failed > 0, + ); + if options.robot_mode { + emit_stage_line(&spinner, &mrs_icon, "MRs", &mrs_summary, mrs_elapsed); + } else { + let sub_rows = mr_sub_rows(&result.mr_projects); + emit_stage_block(&spinner, &mrs_icon, "MRs", &mrs_summary, mrs_elapsed, &sub_rows); + } + + if signal.is_cancelled() { + debug!("Shutdown requested after MRs stage, returning partial sync results"); + return Ok(result); + } + + // ── Stage: Docs ── + if !options.no_docs { + let stage_start = Instant::now(); + let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode); + debug!("Sync: generating documents"); + + let docs_bar = nested_progress("Docs", 0, options.robot_mode); + let docs_bar_clone = docs_bar.clone(); + let docs_cb: Box = Box::new(move |processed, total| { + if total > 0 { + docs_bar_clone.set_length(total as u64); + docs_bar_clone.set_position(processed as u64); + } + }); + let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?; + result.documents_regenerated = docs_result.regenerated; + result.documents_errored = docs_result.errored; + docs_bar.finish_and_clear(); + let mut docs_summary = format!( + "{} documents generated", + format_number(result.documents_regenerated as i64), + ); + append_failures(&mut docs_summary, &[("errors", docs_result.errored)]); + let docs_icon = color_icon( + if docs_result.errored > 0 { + Icons::warning() + } else { + Icons::success() + }, + docs_result.errored > 0, + ); + emit_stage_line(&spinner, &docs_icon, "Docs", &docs_summary, stage_start.elapsed()); + } else { + debug!("Sync: skipping document generation (--no-docs)"); + } + + // ── Stage: Embed ── + if !options.no_embed { + let stage_start = Instant::now(); + let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode); + debug!("Sync: embedding documents"); + + let embed_bar = nested_progress("Embed", 0, options.robot_mode); + let embed_bar_clone = embed_bar.clone(); + let embed_cb: Box = Box::new(move |processed, total| { + if total > 0 { + embed_bar_clone.set_length(total as u64); + embed_bar_clone.set_position(processed as u64); + } + }); + match run_embed(config, options.full, false, Some(embed_cb), signal).await { + Ok(embed_result) => { + result.documents_embedded = embed_result.docs_embedded; + result.embedding_failed = embed_result.failed; + embed_bar.finish_and_clear(); + let mut embed_summary = format!( + "{} chunks embedded", + format_number(embed_result.chunks_embedded as i64), + ); + let mut tail_parts = Vec::new(); + if embed_result.failed > 0 { + tail_parts.push(format!("{} failed", embed_result.failed)); + } + if embed_result.skipped > 0 { + tail_parts.push(format!("{} skipped", embed_result.skipped)); + } + if !tail_parts.is_empty() { + embed_summary.push_str(&format!(" ({})", tail_parts.join(", "))); + } + let embed_icon = color_icon( + if embed_result.failed > 0 { + Icons::warning() + } else { + Icons::success() + }, + embed_result.failed > 0, + ); + emit_stage_line( + &spinner, + &embed_icon, + "Embed", + &embed_summary, + stage_start.elapsed(), + ); + } + Err(e) => { + embed_bar.finish_and_clear(); + let warn_summary = format!("skipped ({})", e); + let warn_icon = color_icon(Icons::warning(), true); + emit_stage_line( + &spinner, + &warn_icon, + "Embed", + &warn_summary, + stage_start.elapsed(), + ); + warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing"); + } + } + } else { + debug!("Sync: skipping embedding (--no-embed)"); + } + + debug!( + issues = result.issues_updated, + mrs = result.mrs_updated, + discussions = result.discussions_fetched, + resource_events = result.resource_events_fetched, + resource_events_failed = result.resource_events_failed, + mr_diffs = result.mr_diffs_fetched, + mr_diffs_failed = result.mr_diffs_failed, + docs = result.documents_regenerated, + embedded = result.documents_embedded, + "Sync pipeline complete" + ); + + Ok(result) + } + .instrument(span) + .await +} + diff --git a/src/cli/commands/sync_surgical.rs b/src/cli/commands/sync/surgical.rs similarity index 99% rename from src/cli/commands/sync_surgical.rs rename to src/cli/commands/sync/surgical.rs index a4952bd..74e2477 100644 --- a/src/cli/commands/sync_surgical.rs +++ b/src/cli/commands/sync/surgical.rs @@ -12,11 +12,11 @@ use crate::core::lock::{AppLock, LockOptions}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::shutdown::ShutdownSignal; -use crate::core::sync_run::SyncRunRecorder; use crate::documents::{SourceType, regenerate_dirty_documents_for_sources}; use crate::embedding::ollama::{OllamaClient, OllamaConfig}; use crate::embedding::pipeline::{DEFAULT_EMBED_CONCURRENCY, embed_documents_by_ids}; use crate::gitlab::GitLabClient; +use crate::ingestion::storage::sync_run::SyncRunRecorder; use crate::ingestion::surgical::{ fetch_dependents_for_issue, fetch_dependents_for_mr, ingest_issue_by_iid, ingest_mr_by_iid, preflight_fetch, diff --git a/src/cli/commands/sync/sync_tests.rs b/src/cli/commands/sync/sync_tests.rs new file mode 100644 index 0000000..bc5d4de --- /dev/null +++ b/src/cli/commands/sync/sync_tests.rs @@ -0,0 +1,268 @@ + use super::*; + + fn default_options() -> SyncOptions { + SyncOptions { + full: false, + force: false, + no_embed: false, + no_docs: false, + no_events: false, + robot_mode: false, + dry_run: false, + issue_iids: vec![], + mr_iids: vec![], + project: None, + preflight_only: false, + } + } + + #[test] + fn append_failures_skips_zeroes() { + let mut summary = "base".to_string(); + append_failures(&mut summary, &[("errors", 0), ("failures", 0)]); + assert_eq!(summary, "base"); + } + + #[test] + fn append_failures_renders_non_zero_counts() { + let mut summary = "base".to_string(); + append_failures(&mut summary, &[("errors", 2), ("failures", 1)]); + assert!(summary.contains("base")); + assert!(summary.contains("2 errors")); + assert!(summary.contains("1 failures")); + } + + #[test] + fn summarize_status_enrichment_reports_skipped_when_all_skipped() { + let projects = vec![ProjectStatusEnrichment { + path: "vs/typescript-code".to_string(), + mode: "skipped".to_string(), + reason: None, + seen: 0, + enriched: 0, + cleared: 0, + without_widget: 0, + partial_errors: 0, + first_partial_error: None, + error: None, + }]; + + let (summary, has_errors) = summarize_status_enrichment(&projects); + assert!(summary.contains("0 statuses updated")); + assert!(summary.contains("skipped")); + assert!(!has_errors); + } + + #[test] + fn summarize_status_enrichment_reports_errors() { + let projects = vec![ProjectStatusEnrichment { + path: "vs/typescript-code".to_string(), + mode: "fetched".to_string(), + reason: None, + seen: 3, + enriched: 1, + cleared: 1, + without_widget: 0, + partial_errors: 2, + first_partial_error: None, + error: Some("boom".to_string()), + }]; + + let (summary, has_errors) = summarize_status_enrichment(&projects); + assert!(summary.contains("1 statuses updated")); + assert!(summary.contains("1 cleared")); + assert!(summary.contains("3 seen")); + assert!(summary.contains("3 errors")); + assert!(has_errors); + } + + #[test] + fn should_print_timings_only_when_enabled_and_non_empty() { + let stages = vec![StageTiming { + name: "x".to_string(), + elapsed_ms: 10, + items_processed: 0, + items_skipped: 0, + errors: 0, + rate_limit_hits: 0, + retries: 0, + project: None, + sub_stages: vec![], + }]; + + assert!(should_print_timings(true, &stages)); + assert!(!should_print_timings(false, &stages)); + assert!(!should_print_timings(true, &[])); + } + + #[test] + fn issue_sub_rows_include_project_and_statuses() { + let rows = issue_sub_rows(&[ProjectSummary { + path: "vs/typescript-code".to_string(), + items_upserted: 2, + discussions_synced: 0, + events_fetched: 0, + events_failed: 0, + statuses_enriched: 1, + statuses_seen: 5, + status_errors: 0, + mr_diffs_fetched: 0, + mr_diffs_failed: 0, + }]); + + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("vs/typescript-code")); + assert!(rows[0].contains("2 issues")); + assert!(rows[0].contains("1 statuses updated")); + } + + #[test] + fn mr_sub_rows_include_project_and_diff_failures() { + let rows = mr_sub_rows(&[ProjectSummary { + path: "vs/python-code".to_string(), + items_upserted: 3, + discussions_synced: 0, + events_fetched: 0, + events_failed: 0, + statuses_enriched: 0, + statuses_seen: 0, + status_errors: 0, + mr_diffs_fetched: 4, + mr_diffs_failed: 1, + }]); + + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("vs/python-code")); + assert!(rows[0].contains("3 MRs")); + assert!(rows[0].contains("4 diffs")); + assert!(rows[0].contains("1 diff failures")); + } + + #[test] + fn status_sub_rows_include_project_and_skip_reason() { + let rows = status_sub_rows(&[ProjectStatusEnrichment { + path: "vs/python-code".to_string(), + mode: "skipped".to_string(), + reason: Some("disabled".to_string()), + seen: 0, + enriched: 0, + cleared: 0, + without_widget: 0, + partial_errors: 0, + first_partial_error: None, + error: None, + }]); + + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("vs/python-code")); + assert!(rows[0].contains("0 statuses updated")); + assert!(rows[0].contains("skipped (disabled)")); + } + + #[test] + fn is_surgical_with_issues() { + let opts = SyncOptions { + issue_iids: vec![1], + ..default_options() + }; + assert!(opts.is_surgical()); + } + + #[test] + fn is_surgical_with_mrs() { + let opts = SyncOptions { + mr_iids: vec![10], + ..default_options() + }; + assert!(opts.is_surgical()); + } + + #[test] + fn is_surgical_empty() { + let opts = default_options(); + assert!(!opts.is_surgical()); + } + + #[test] + fn max_surgical_targets_is_100() { + assert_eq!(SyncOptions::MAX_SURGICAL_TARGETS, 100); + } + + #[test] + fn sync_result_default_omits_surgical_fields() { + let result = SyncResult::default(); + let json = serde_json::to_value(&result).unwrap(); + assert!(json.get("surgical_mode").is_none()); + assert!(json.get("surgical_iids").is_none()); + assert!(json.get("entity_results").is_none()); + assert!(json.get("preflight_only").is_none()); + } + + #[test] + fn sync_result_with_surgical_fields_serializes_correctly() { + let result = SyncResult { + surgical_mode: Some(true), + surgical_iids: Some(SurgicalIids { + issues: vec![7, 42], + merge_requests: vec![10], + }), + entity_results: Some(vec![ + EntitySyncResult { + entity_type: "issue".to_string(), + iid: 7, + outcome: "synced".to_string(), + error: None, + toctou_reason: None, + }, + EntitySyncResult { + entity_type: "issue".to_string(), + iid: 42, + outcome: "skipped_toctou".to_string(), + error: None, + toctou_reason: Some("updated_at changed".to_string()), + }, + ]), + preflight_only: Some(false), + ..SyncResult::default() + }; + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["surgical_mode"], true); + assert_eq!(json["surgical_iids"]["issues"], serde_json::json!([7, 42])); + assert_eq!(json["entity_results"].as_array().unwrap().len(), 2); + assert_eq!(json["entity_results"][1]["outcome"], "skipped_toctou"); + assert_eq!(json["preflight_only"], false); + } + + #[test] + fn entity_sync_result_omits_none_fields() { + let entity = EntitySyncResult { + entity_type: "merge_request".to_string(), + iid: 10, + outcome: "synced".to_string(), + error: None, + toctou_reason: None, + }; + let json = serde_json::to_value(&entity).unwrap(); + assert!(json.get("error").is_none()); + assert!(json.get("toctou_reason").is_none()); + assert!(json.get("entity_type").is_some()); + } + + #[test] + fn is_surgical_with_both_issues_and_mrs() { + let opts = SyncOptions { + issue_iids: vec![1, 2], + mr_iids: vec![10], + ..default_options() + }; + assert!(opts.is_surgical()); + } + + #[test] + fn is_not_surgical_with_only_project() { + let opts = SyncOptions { + project: Some("group/repo".to_string()), + ..default_options() + }; + assert!(!opts.is_surgical()); + } diff --git a/src/cli/commands/timeline.rs b/src/cli/commands/timeline.rs index 4f428a5..5c8e8f0 100644 --- a/src/cli/commands/timeline.rs +++ b/src/cli/commands/timeline.rs @@ -8,13 +8,13 @@ use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, parse_since}; -use crate::core::timeline::{ +use crate::embedding::ollama::{OllamaClient, OllamaConfig}; +use crate::timeline::collect::collect_events; +use crate::timeline::expand::expand_timeline; +use crate::timeline::seed::{seed_timeline, seed_timeline_direct}; +use crate::timeline::{ EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType, TimelineResult, UnresolvedRef, }; -use crate::core::timeline_collect::collect_events; -use crate::core::timeline_expand::expand_timeline; -use crate::core::timeline_seed::{seed_timeline, seed_timeline_direct}; -use crate::embedding::ollama::{OllamaClient, OllamaConfig}; /// Parameters for running the timeline pipeline. pub struct TimelineParams { diff --git a/src/cli/commands/who_tests.rs b/src/cli/commands/who_tests.rs index 5a4f253..f5f9c94 100644 --- a/src/cli/commands/who_tests.rs +++ b/src/cli/commands/who_tests.rs @@ -1,12 +1,5 @@ use super::*; -use crate::core::db::{create_connection, run_migrations}; -use std::path::Path; - -fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn -} +use crate::test_support::{insert_project, setup_test_db}; fn default_scoring() -> ScoringConfig { ScoringConfig::default() @@ -17,20 +10,6 @@ fn test_as_of_ms() -> i64 { now_ms() + 1000 } -fn insert_project(conn: &Connection, id: i64, path: &str) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) - VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![ - id, - id * 100, - path, - format!("https://git.example.com/{}", path) - ], - ) - .unwrap(); -} - fn insert_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str) { let ts = now_ms(); conn.execute( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9d0cfe5..109b47a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,10 +1,11 @@ +pub mod args; pub mod autocorrect; pub mod commands; pub mod progress; pub mod render; pub mod robot; -use clap::{Args, Parser, Subcommand}; +use clap::{Parser, Subcommand}; use std::io::IsTerminal; #[derive(Parser)] @@ -398,871 +399,8 @@ pub enum Commands { SyncStatus, } -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore issues -n 10 # List 10 most recently updated issues - lore issues -s opened -l bug # Open issues labeled 'bug' - lore issues 42 -p group/repo # Show issue #42 in a specific project - lore issues --since 7d -a jsmith # Issues updated in last 7 days by jsmith")] -pub struct IssuesArgs { - /// Issue IID (omit to list, provide to show details) - pub iid: Option, - - /// Maximum results - #[arg( - short = 'n', - long = "limit", - default_value = "50", - help_heading = "Output" - )] - pub limit: usize, - - /// Select output fields (comma-separated, or 'minimal' preset: iid,title,state,updated_at_iso) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Filter by state (opened, closed, all) - #[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "closed", "all"])] - pub state: Option, - - /// Filter by project path - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Filter by author username - #[arg(short = 'a', long, help_heading = "Filters")] - pub author: Option, - - /// Filter by assignee username - #[arg(short = 'A', long, help_heading = "Filters")] - pub assignee: Option, - - /// Filter by label (repeatable, AND logic) - #[arg(short = 'l', long, help_heading = "Filters")] - pub label: Option>, - - /// Filter by milestone title - #[arg(short = 'm', long, help_heading = "Filters")] - pub milestone: Option, - - /// Filter by work-item status name (repeatable, OR logic) - #[arg(long, help_heading = "Filters")] - pub status: Vec, - - /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Filter by due date (before this date, YYYY-MM-DD) - #[arg(long = "due-before", help_heading = "Filters")] - pub due_before: Option, - - /// Show only issues with a due date - #[arg( - long = "has-due", - help_heading = "Filters", - overrides_with = "no_has_due" - )] - pub has_due: bool, - - #[arg(long = "no-has-due", hide = true, overrides_with = "has_due")] - pub no_has_due: bool, - - /// Sort field (updated, created, iid) - #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] - pub sort: String, - - /// Sort ascending (default: descending) - #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] - pub asc: bool, - - #[arg(long = "no-asc", hide = true, overrides_with = "asc")] - pub no_asc: bool, - - /// Open first matching item in browser - #[arg( - short = 'o', - long, - help_heading = "Actions", - overrides_with = "no_open" - )] - pub open: bool, - - #[arg(long = "no-open", hide = true, overrides_with = "open")] - pub no_open: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore mrs -s opened # List open merge requests - lore mrs -s merged --since 2w # MRs merged in the last 2 weeks - lore mrs 99 -p group/repo # Show MR !99 in a specific project - lore mrs -D --reviewer jsmith # Non-draft MRs reviewed by jsmith")] -pub struct MrsArgs { - /// MR IID (omit to list, provide to show details) - pub iid: Option, - - /// Maximum results - #[arg( - short = 'n', - long = "limit", - default_value = "50", - help_heading = "Output" - )] - pub limit: usize, - - /// Select output fields (comma-separated, or 'minimal' preset: iid,title,state,updated_at_iso) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Filter by state (opened, merged, closed, locked, all) - #[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "merged", "closed", "locked", "all"])] - pub state: Option, - - /// Filter by project path - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Filter by author username - #[arg(short = 'a', long, help_heading = "Filters")] - pub author: Option, - - /// Filter by assignee username - #[arg(short = 'A', long, help_heading = "Filters")] - pub assignee: Option, - - /// Filter by reviewer username - #[arg(short = 'r', long, help_heading = "Filters")] - pub reviewer: Option, - - /// Filter by label (repeatable, AND logic) - #[arg(short = 'l', long, help_heading = "Filters")] - pub label: Option>, - - /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Show only draft MRs - #[arg( - short = 'd', - long, - conflicts_with = "no_draft", - help_heading = "Filters" - )] - pub draft: bool, - - /// Exclude draft MRs - #[arg( - short = 'D', - long = "no-draft", - conflicts_with = "draft", - help_heading = "Filters" - )] - pub no_draft: bool, - - /// Filter by target branch - #[arg(long, help_heading = "Filters")] - pub target: Option, - - /// Filter by source branch - #[arg(long, help_heading = "Filters")] - pub source: Option, - - /// Sort field (updated, created, iid) - #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] - pub sort: String, - - /// Sort ascending (default: descending) - #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] - pub asc: bool, - - #[arg(long = "no-asc", hide = true, overrides_with = "asc")] - pub no_asc: bool, - - /// Open first matching item in browser - #[arg( - short = 'o', - long, - help_heading = "Actions", - overrides_with = "no_open" - )] - pub open: bool, - - #[arg(long = "no-open", hide = true, overrides_with = "open")] - pub no_open: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore notes # List 50 most recent notes - lore notes --author alice --since 7d # Notes by alice in last 7 days - lore notes --for-issue 42 -p group/repo # Notes on issue #42 - lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/")] -pub struct NotesArgs { - /// Maximum results - #[arg( - short = 'n', - long = "limit", - default_value = "50", - help_heading = "Output" - )] - pub limit: usize, - - /// Select output fields (comma-separated, or 'minimal' preset: id,author_username,body,created_at_iso) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Filter by author username - #[arg(short = 'a', long, help_heading = "Filters")] - pub author: Option, - - /// Filter by note type (DiffNote, DiscussionNote) - #[arg(long, help_heading = "Filters")] - pub note_type: Option, - - /// Filter by body text (substring match) - #[arg(long, help_heading = "Filters")] - pub contains: Option, - - /// Filter by internal note ID - #[arg(long, help_heading = "Filters")] - pub note_id: Option, - - /// Filter by GitLab note ID - #[arg(long, help_heading = "Filters")] - pub gitlab_note_id: Option, - - /// Filter by discussion ID - #[arg(long, help_heading = "Filters")] - pub discussion_id: Option, - - /// Include system notes (excluded by default) - #[arg(long, help_heading = "Filters")] - pub include_system: bool, - - /// Filter to notes on a specific issue IID (requires --project or default_project) - #[arg(long, conflicts_with = "for_mr", help_heading = "Filters")] - pub for_issue: Option, - - /// Filter to notes on a specific MR IID (requires --project or default_project) - #[arg(long, conflicts_with = "for_issue", help_heading = "Filters")] - pub for_mr: Option, - - /// Filter by project path - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Filter until date (YYYY-MM-DD, inclusive end-of-day) - #[arg(long, help_heading = "Filters")] - pub until: Option, - - /// Filter by file path (exact match or prefix with trailing /) - #[arg(long, help_heading = "Filters")] - pub path: Option, - - /// Filter by resolution status (any, unresolved, resolved) - #[arg( - long, - value_parser = ["any", "unresolved", "resolved"], - help_heading = "Filters" - )] - pub resolution: Option, - - /// Sort field (created, updated) - #[arg( - long, - value_parser = ["created", "updated"], - default_value = "created", - help_heading = "Sorting" - )] - pub sort: String, - - /// Sort ascending (default: descending) - #[arg(long, help_heading = "Sorting")] - pub asc: bool, - - /// Open first matching item in browser - #[arg(long, help_heading = "Actions")] - pub open: bool, -} - -#[derive(Parser)] -pub struct IngestArgs { - /// Entity to ingest (issues, mrs). Omit to ingest everything - #[arg(value_parser = ["issues", "mrs"])] - pub entity: Option, - - /// Filter to single project - #[arg(short = 'p', long)] - pub project: Option, - - /// Override stale sync lock - #[arg(short = 'f', long, overrides_with = "no_force")] - pub force: bool, - - #[arg(long = "no-force", hide = true, overrides_with = "force")] - pub no_force: bool, - - /// Full re-sync: reset cursors and fetch all data from scratch - #[arg(long, overrides_with = "no_full")] - pub full: bool, - - #[arg(long = "no-full", hide = true, overrides_with = "full")] - pub no_full: bool, - - /// Preview what would be synced without making changes - #[arg(long, overrides_with = "no_dry_run")] - pub dry_run: bool, - - #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] - pub no_dry_run: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore stats # Show document and index statistics - lore stats --check # Run integrity checks - lore stats --repair --dry-run # Preview what repair would fix - lore --robot stats # JSON output for automation")] -pub struct StatsArgs { - /// Run integrity checks - #[arg(long, overrides_with = "no_check")] - pub check: bool, - - #[arg(long = "no-check", hide = true, overrides_with = "check")] - pub no_check: bool, - - /// Repair integrity issues (auto-enables --check) - #[arg(long)] - pub repair: bool, - - /// Preview what would be repaired without making changes (requires --repair) - #[arg(long, overrides_with = "no_dry_run")] - pub dry_run: bool, - - #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] - pub no_dry_run: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore search 'authentication bug' # Hybrid search (default) - lore search 'deploy' --mode lexical --type mr # Lexical search, MRs only - lore search 'API rate limit' --since 30d # Recent results only - lore search 'config' -p group/repo --explain # With ranking explanation")] -pub struct SearchArgs { - /// Search query string - pub query: String, - - /// Search mode (lexical, hybrid, semantic) - #[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Mode")] - pub mode: String, - - /// Filter by source type (issue, mr, discussion, note) - #[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion", "note"], help_heading = "Filters")] - pub source_type: Option, - - /// Filter by author username - #[arg(long, help_heading = "Filters")] - pub author: Option, - - /// Filter by project path - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Filter by label (repeatable, AND logic) - #[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")] - pub label: Vec, - - /// Filter by file path (trailing / for prefix match) - #[arg(long, help_heading = "Filters")] - pub path: Option, - - /// Filter by created since (7d, 2w, or YYYY-MM-DD) - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Filter by updated since (7d, 2w, or YYYY-MM-DD) - #[arg(long = "updated-since", help_heading = "Filters")] - pub updated_since: Option, - - /// Maximum results (default 20, max 100) - #[arg( - short = 'n', - long = "limit", - default_value = "20", - help_heading = "Output" - )] - pub limit: usize, - - /// Select output fields (comma-separated, or 'minimal' preset: document_id,title,source_type,score) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Show ranking explanation per result - #[arg(long, help_heading = "Output", overrides_with = "no_explain")] - pub explain: bool, - - #[arg(long = "no-explain", hide = true, overrides_with = "explain")] - pub no_explain: bool, - - /// FTS query mode: safe (default) or raw - #[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Mode")] - pub fts_mode: String, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore generate-docs # Generate docs for dirty entities - lore generate-docs --full # Full rebuild of all documents - lore generate-docs --full -p group/repo # Full rebuild for one project")] -pub struct GenerateDocsArgs { - /// Full rebuild: seed all entities into dirty queue, then drain - #[arg(long)] - pub full: bool, - - /// Filter to single project - #[arg(short = 'p', long)] - pub project: Option, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore sync # Full pipeline: ingest + docs + embed - lore sync --no-embed # Skip embedding step - lore sync --no-status # Skip work-item status enrichment - lore sync --full --force # Full re-sync, override stale lock - lore sync --dry-run # Preview what would change - lore sync --issue 42 -p group/repo # Surgically sync one issue - lore sync --mr 10 --mr 20 -p g/r # Surgically sync two MRs")] -pub struct SyncArgs { - /// Reset cursors, fetch everything - #[arg(long, overrides_with = "no_full")] - pub full: bool, - - #[arg(long = "no-full", hide = true, overrides_with = "full")] - pub no_full: bool, - - /// Override stale lock - #[arg(long, overrides_with = "no_force")] - pub force: bool, - - #[arg(long = "no-force", hide = true, overrides_with = "force")] - pub no_force: bool, - - /// Skip embedding step - #[arg(long)] - pub no_embed: bool, - - /// Skip document regeneration - #[arg(long)] - pub no_docs: bool, - - /// Skip resource event fetching (overrides config) - #[arg(long = "no-events")] - pub no_events: bool, - - /// Skip MR file change fetching (overrides config) - #[arg(long = "no-file-changes")] - pub no_file_changes: bool, - - /// Skip work-item status enrichment via GraphQL (overrides config) - #[arg(long = "no-status")] - pub no_status: bool, - - /// Preview what would be synced without making changes - #[arg(long, overrides_with = "no_dry_run")] - pub dry_run: bool, - - #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] - pub no_dry_run: bool, - - /// Show detailed timing breakdown for sync stages - #[arg(short = 't', long = "timings")] - pub timings: bool, - - /// Acquire file lock before syncing (skip if another sync is running) - #[arg(long)] - pub lock: bool, - - /// Surgically sync specific issues by IID (repeatable, must be positive) - #[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)] - pub issue: Vec, - - /// Surgically sync specific merge requests by IID (repeatable, must be positive) - #[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)] - pub mr: Vec, - - /// Scope to a single project (required when --issue or --mr is used) - #[arg(short = 'p', long)] - pub project: Option, - - /// Validate remote entities exist without DB writes (preflight only) - #[arg(long)] - pub preflight_only: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore embed # Embed new/changed documents - lore embed --full # Re-embed all documents from scratch - lore embed --retry-failed # Retry previously failed embeddings")] -pub struct EmbedArgs { - /// Re-embed all documents (clears existing embeddings first) - #[arg(long, overrides_with = "no_full")] - pub full: bool, - - #[arg(long = "no-full", hide = true, overrides_with = "full")] - pub no_full: bool, - - /// Retry previously failed embeddings - #[arg(long, overrides_with = "no_retry_failed")] - pub retry_failed: bool, - - #[arg(long = "no-retry-failed", hide = true, overrides_with = "retry_failed")] - pub no_retry_failed: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore timeline 'deployment' # Search-based seeding - lore timeline issue:42 # Direct: issue #42 and related entities - lore timeline i:42 # Shorthand for issue:42 - lore timeline mr:99 # Direct: MR !99 and related entities - lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time - lore timeline 'migration' --depth 2 # Deep cross-reference expansion - lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")] -pub struct TimelineArgs { - /// Search text or entity reference (issue:N, i:N, mr:N, m:N) - pub query: String, - - /// Scope to a specific project (fuzzy match) - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Only show events after this date (e.g. "6m", "2w", "2024-01-01") - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Cross-reference expansion depth (0 = no expansion) - #[arg(long, default_value = "1", help_heading = "Expansion")] - pub depth: u32, - - /// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related') - #[arg(long = "no-mentions", help_heading = "Expansion")] - pub no_mentions: bool, - - /// Maximum number of events to display - #[arg( - short = 'n', - long = "limit", - default_value = "100", - help_heading = "Output" - )] - pub limit: usize, - - /// Select output fields (comma-separated, or 'minimal' preset: timestamp,type,entity_iid,detail) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Maximum seed entities from search - #[arg(long = "max-seeds", default_value = "10", help_heading = "Expansion")] - pub max_seeds: usize, - - /// Maximum expanded entities via cross-references - #[arg( - long = "max-entities", - default_value = "50", - help_heading = "Expansion" - )] - pub max_entities: usize, - - /// Maximum evidence notes included - #[arg( - long = "max-evidence", - default_value = "10", - help_heading = "Expansion" - )] - pub max_evidence: usize, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore who src/features/auth/ # Who knows about this area? - lore who @asmith # What is asmith working on? - lore who @asmith --reviews # What review patterns does asmith have? - lore who --active # What discussions need attention? - lore who --overlap src/features/auth/ # Who else is touching these files? - lore who --path README.md # Expert lookup for a root file - lore who --path Makefile # Expert lookup for a dotless root file")] -pub struct WhoArgs { - /// Username or file path (path if contains /) - pub target: Option, - - /// Force expert mode for a file/directory path. - /// Root files (README.md, LICENSE, Makefile) are treated as exact matches. - /// Use a trailing `/` to force directory-prefix matching. - #[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])] - pub path: Option, - - /// Show active unresolved discussions - #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])] - pub active: bool, - - /// Find users with MRs/notes touching this file path - #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])] - pub overlap: Option, - - /// Show review pattern analysis (requires username target) - #[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])] - pub reviews: bool, - - /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Scope to a project (supports fuzzy matching) - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Maximum results per section (1..=500); omit for unlimited - #[arg( - short = 'n', - long = "limit", - value_parser = clap::value_parser!(u16).range(1..=500), - help_heading = "Output" - )] - pub limit: Option, - - /// Select output fields (comma-separated, or 'minimal' preset; varies by mode) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Show per-MR detail breakdown (expert mode only) - #[arg( - long, - help_heading = "Output", - overrides_with = "no_detail", - conflicts_with = "explain_score" - )] - pub detail: bool, - - #[arg(long = "no-detail", hide = true, overrides_with = "detail")] - pub no_detail: bool, - - /// Score as if "now" is this date (ISO 8601 or duration like 30d). Expert mode only. - #[arg(long = "as-of", help_heading = "Scoring")] - pub as_of: Option, - - /// Show per-component score breakdown in output. Expert mode only. - #[arg(long = "explain-score", help_heading = "Scoring")] - pub explain_score: bool, - - /// Include bot users in results (normally excluded via scoring.excluded_usernames). - #[arg(long = "include-bots", help_heading = "Scoring")] - pub include_bots: bool, - - /// Include discussions on closed issues and merged/closed MRs - #[arg(long, help_heading = "Filters")] - pub include_closed: bool, - - /// Remove the default time window (query all history). Conflicts with --since. - #[arg( - long = "all-history", - help_heading = "Filters", - conflicts_with = "since" - )] - pub all_history: bool, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore me # Full dashboard (default project or all) - lore me --issues # Issues section only - lore me --mrs # MRs section only - lore me --activity # Activity feed only - lore me --all # All synced projects - lore me --since 2d # Activity window (default: 30d) - lore me --project group/repo # Scope to one project - lore me --user jdoe # Override configured username")] -pub struct MeArgs { - /// Show open issues section - #[arg(long, help_heading = "Sections")] - pub issues: bool, - - /// Show authored + reviewing MRs section - #[arg(long, help_heading = "Sections")] - pub mrs: bool, - - /// Show activity feed section - #[arg(long, help_heading = "Sections")] - pub activity: bool, - - /// Show items you're @mentioned in (not assigned/authored/reviewing) - #[arg(long, help_heading = "Sections")] - pub mentions: bool, - - /// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section. - #[arg(long, help_heading = "Filters")] - pub since: Option, - - /// Scope to a project (supports fuzzy matching) - #[arg(short = 'p', long, help_heading = "Filters", conflicts_with = "all")] - pub project: Option, - - /// Show all synced projects (overrides default_project) - #[arg(long, help_heading = "Filters", conflicts_with = "project")] - pub all: bool, - - /// Override configured username - #[arg(long = "user", help_heading = "Filters")] - pub user: Option, - - /// Select output fields (comma-separated, or 'minimal' preset) - #[arg(long, help_heading = "Output", value_delimiter = ',')] - pub fields: Option>, - - /// Reset the since-last-check cursor (next run shows no new events) - #[arg(long, help_heading = "Output")] - pub reset_cursor: bool, -} - -impl MeArgs { - /// Returns true if no section flags were passed (show all sections). - pub fn show_all_sections(&self) -> bool { - !self.issues && !self.mrs && !self.activity && !self.mentions - } -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore file-history src/main.rs # MRs that touched this file - lore file-history src/auth/ -p group/repo # Scoped to project - lore file-history src/foo.rs --discussions # Include DiffNote snippets - lore file-history src/bar.rs --no-follow-renames # Skip rename chain")] -pub struct FileHistoryArgs { - /// File path to trace history for - pub path: String, - - /// Scope to a specific project (fuzzy match) - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Include discussion snippets from DiffNotes on this file - #[arg(long, help_heading = "Output")] - pub discussions: bool, - - /// Disable rename chain resolution - #[arg(long = "no-follow-renames", help_heading = "Filters")] - pub no_follow_renames: bool, - - /// Only show merged MRs - #[arg(long, help_heading = "Filters")] - pub merged: bool, - - /// Maximum results - #[arg( - short = 'n', - long = "limit", - default_value = "50", - help_heading = "Output" - )] - pub limit: usize, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore trace src/main.rs # Why was this file changed? - lore trace src/auth/ -p group/repo # Scoped to project - lore trace src/foo.rs --discussions # Include DiffNote context - lore trace src/bar.rs:42 # Line hint (Tier 2 warning)")] -pub struct TraceArgs { - /// File path to trace (supports :line suffix for future Tier 2) - pub path: String, - - /// Scope to a specific project (fuzzy match) - #[arg(short = 'p', long, help_heading = "Filters")] - pub project: Option, - - /// Include DiffNote discussion snippets - #[arg(long, help_heading = "Output")] - pub discussions: bool, - - /// Disable rename chain resolution - #[arg(long = "no-follow-renames", help_heading = "Filters")] - pub no_follow_renames: bool, - - /// Maximum trace chains to display - #[arg( - short = 'n', - long = "limit", - default_value = "20", - help_heading = "Output" - )] - pub limit: usize, -} - -#[derive(Parser)] -#[command(after_help = "\x1b[1mExamples:\x1b[0m - lore count issues # Total issues in local database - lore count notes --for mr # Notes on merge requests only - lore count discussions --for issue # Discussions on issues only")] -pub struct CountArgs { - /// Entity type to count (issues, mrs, discussions, notes, events) - #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] - pub entity: String, - - /// Parent type filter: issue or mr (for discussions/notes) - #[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])] - pub for_entity: Option, -} - -#[derive(Parser)] -pub struct CronArgs { - #[command(subcommand)] - pub action: CronAction, -} - -#[derive(Subcommand)] -pub enum CronAction { - /// Install cron job for automatic syncing - Install { - /// Sync interval in minutes (default: 8) - #[arg(long, default_value = "8")] - interval: u32, - }, - - /// Remove cron job - Uninstall, - - /// Show current cron configuration - Status, -} - -#[derive(Args)] -pub struct TokenArgs { - #[command(subcommand)] - pub action: TokenAction, -} - -#[derive(Subcommand)] -pub enum TokenAction { - /// Store a GitLab token in the config file - Set { - /// Token value (reads from stdin if omitted in non-interactive mode) - #[arg(long)] - token: Option, - }, - - /// Show the current token (masked by default) - Show { - /// Show the full unmasked token - #[arg(long)] - unmask: bool, - }, -} +pub use args::{ + CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, IngestArgs, + IssuesArgs, MeArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, + TokenAction, TokenArgs, TraceArgs, WhoArgs, +}; diff --git a/src/documents/extractor.rs b/src/documents/extractor.rs deleted file mode 100644 index 3537235..0000000 --- a/src/documents/extractor.rs +++ /dev/null @@ -1,1059 +0,0 @@ -use chrono::DateTime; -use rusqlite::Connection; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeSet, HashMap}; -use std::fmt::Write as _; - -use super::truncation::{ - MAX_DISCUSSION_BYTES, MAX_DOCUMENT_BYTES_HARD, NoteContent, pre_truncate_description, - truncate_discussion, truncate_hard_cap, -}; -use crate::core::error::Result; -use crate::core::time::ms_to_iso; -use tracing::warn; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SourceType { - Issue, - MergeRequest, - Discussion, - Note, -} - -impl SourceType { - pub fn as_str(&self) -> &'static str { - match self { - Self::Issue => "issue", - Self::MergeRequest => "merge_request", - Self::Discussion => "discussion", - Self::Note => "note", - } - } - - pub fn parse(s: &str) -> Option { - match s.to_lowercase().as_str() { - "issue" | "issues" => Some(Self::Issue), - "mr" | "mrs" | "merge_request" | "merge_requests" => Some(Self::MergeRequest), - "discussion" | "discussions" => Some(Self::Discussion), - "note" | "notes" => Some(Self::Note), - _ => None, - } - } -} - -impl std::fmt::Display for SourceType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -#[derive(Debug, Clone)] -pub struct DocumentData { - pub source_type: SourceType, - pub source_id: i64, - pub project_id: i64, - pub author_username: Option, - pub labels: Vec, - pub paths: Vec, - pub labels_hash: String, - pub paths_hash: String, - pub created_at: i64, - pub updated_at: i64, - pub url: Option, - pub title: Option, - pub content_text: String, - pub content_hash: String, - pub is_truncated: bool, - pub truncated_reason: Option, -} - -pub fn compute_content_hash(content: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -pub fn compute_list_hash(items: &[String]) -> String { - let mut indices: Vec = (0..items.len()).collect(); - indices.sort_by(|a, b| items[*a].cmp(&items[*b])); - let mut hasher = Sha256::new(); - for (i, &idx) in indices.iter().enumerate() { - if i > 0 { - hasher.update(b"\n"); - } - hasher.update(items[idx].as_bytes()); - } - format!("{:x}", hasher.finalize()) -} - -pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result> { - let row = conn.query_row( - "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, - i.created_at, i.updated_at, i.web_url, - p.path_with_namespace, p.id AS project_id - FROM issues i - JOIN projects p ON p.id = i.project_id - WHERE i.id = ?1", - rusqlite::params![issue_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, String>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, i64>(6)?, - row.get::<_, i64>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, String>(9)?, - row.get::<_, i64>(10)?, - )) - }, - ); - - let ( - id, - iid, - title, - description, - state, - author_username, - created_at, - updated_at, - web_url, - path_with_namespace, - project_id, - ) = match row { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM issue_labels il - JOIN labels l ON l.id = il.label_id - WHERE il.issue_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![id], |row| row.get(0))? - .collect::, _>>()?; - - let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string()); - - let display_title = title.as_deref().unwrap_or("(untitled)"); - let mut content = format!( - "[[Issue]] #{}: {}\nProject: {}\n", - iid, display_title, path_with_namespace - ); - if let Some(ref url) = web_url { - let _ = writeln!(content, "URL: {}", url); - } - let _ = writeln!(content, "Labels: {}", labels_json); - let _ = writeln!(content, "State: {}", state); - if let Some(ref author) = author_username { - let _ = writeln!(content, "Author: @{}", author); - } - - if let Some(ref desc) = description { - content.push_str("\n--- Description ---\n\n"); - // Pre-truncate to avoid unbounded memory allocation for huge descriptions - let pre_trunc = pre_truncate_description(desc, MAX_DOCUMENT_BYTES_HARD); - if pre_trunc.was_truncated { - warn!( - iid, - original_bytes = pre_trunc.original_bytes, - "Issue description truncated (oversized)" - ); - } - content.push_str(&pre_trunc.content); - } - - let labels_hash = compute_list_hash(&labels); - let paths_hash = compute_list_hash(&[]); - - let hard_cap = truncate_hard_cap(&content); - let content_hash = compute_content_hash(&hard_cap.content); - - Ok(Some(DocumentData { - source_type: SourceType::Issue, - source_id: id, - project_id, - author_username, - labels, - paths: Vec::new(), - labels_hash, - paths_hash, - created_at, - updated_at, - url: web_url, - title: Some(display_title.to_string()), - content_text: hard_cap.content, - content_hash, - is_truncated: hard_cap.is_truncated, - truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()), - })) -} - -pub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result> { - let row = conn.query_row( - "SELECT m.id, m.iid, m.title, m.description, m.state, m.author_username, - m.source_branch, m.target_branch, - m.created_at, m.updated_at, m.web_url, - p.path_with_namespace, p.id AS project_id - FROM merge_requests m - JOIN projects p ON p.id = m.project_id - WHERE m.id = ?1", - rusqlite::params![mr_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, String>(11)?, - row.get::<_, i64>(12)?, - )) - }, - ); - - let ( - id, - iid, - title, - description, - state, - author_username, - source_branch, - target_branch, - created_at, - updated_at, - web_url, - path_with_namespace, - project_id, - ) = match row { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM mr_labels ml - JOIN labels l ON l.id = ml.label_id - WHERE ml.merge_request_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![id], |row| row.get(0))? - .collect::, _>>()?; - - let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string()); - - let display_title = title.as_deref().unwrap_or("(untitled)"); - let display_state = state.as_deref().unwrap_or("unknown"); - let mut content = format!( - "[[MergeRequest]] !{}: {}\nProject: {}\n", - iid, display_title, path_with_namespace - ); - if let Some(ref url) = web_url { - let _ = writeln!(content, "URL: {}", url); - } - let _ = writeln!(content, "Labels: {}", labels_json); - let _ = writeln!(content, "State: {}", display_state); - if let Some(ref author) = author_username { - let _ = writeln!(content, "Author: @{}", author); - } - if let (Some(src), Some(tgt)) = (&source_branch, &target_branch) { - let _ = writeln!(content, "Source: {} -> {}", src, tgt); - } - - if let Some(ref desc) = description { - content.push_str("\n--- Description ---\n\n"); - // Pre-truncate to avoid unbounded memory allocation for huge descriptions - let pre_trunc = pre_truncate_description(desc, MAX_DOCUMENT_BYTES_HARD); - if pre_trunc.was_truncated { - warn!( - iid, - original_bytes = pre_trunc.original_bytes, - "MR description truncated (oversized)" - ); - } - content.push_str(&pre_trunc.content); - } - - let labels_hash = compute_list_hash(&labels); - let paths_hash = compute_list_hash(&[]); - - let hard_cap = truncate_hard_cap(&content); - let content_hash = compute_content_hash(&hard_cap.content); - - Ok(Some(DocumentData { - source_type: SourceType::MergeRequest, - source_id: id, - project_id, - author_username, - labels, - paths: Vec::new(), - labels_hash, - paths_hash, - created_at: created_at.unwrap_or(0), - updated_at: updated_at.unwrap_or(0), - url: web_url, - title: Some(display_title.to_string()), - content_text: hard_cap.content, - content_hash, - is_truncated: hard_cap.is_truncated, - truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()), - })) -} - -fn format_date(ms: i64) -> String { - DateTime::from_timestamp_millis(ms) - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| "unknown".to_string()) -} - -pub fn extract_discussion_document( - conn: &Connection, - discussion_id: i64, -) -> Result> { - let disc_row = conn.query_row( - "SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, - p.path_with_namespace, p.id AS project_id - FROM discussions d - JOIN projects p ON p.id = d.project_id - WHERE d.id = ?1", - rusqlite::params![discussion_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, String>(4)?, - row.get::<_, i64>(5)?, - )) - }, - ); - - let (id, noteable_type, issue_id, merge_request_id, path_with_namespace, project_id) = - match disc_row { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - - let (_parent_iid, parent_title, parent_web_url, parent_type_prefix, labels) = - match noteable_type.as_str() { - "Issue" => { - let parent_id = match issue_id { - Some(pid) => pid, - None => return Ok(None), - }; - let parent = conn.query_row( - "SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1", - rusqlite::params![parent_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (iid, title, web_url) = match parent { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM issue_labels il - JOIN labels l ON l.id = il.label_id - WHERE il.issue_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![parent_id], |row| row.get(0))? - .collect::, _>>()?; - - (iid, title, web_url, format!("Issue #{}", iid), labels) - } - "MergeRequest" => { - let parent_id = match merge_request_id { - Some(pid) => pid, - None => return Ok(None), - }; - let parent = conn.query_row( - "SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1", - rusqlite::params![parent_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (iid, title, web_url) = match parent { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM mr_labels ml - JOIN labels l ON l.id = ml.label_id - WHERE ml.merge_request_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![parent_id], |row| row.get(0))? - .collect::, _>>()?; - - (iid, title, web_url, format!("MR !{}", iid), labels) - } - _ => return Ok(None), - }; - - let mut note_stmt = conn.prepare_cached( - "SELECT n.author_username, n.body, n.created_at, n.gitlab_id, - n.note_type, n.position_old_path, n.position_new_path - FROM notes n - WHERE n.discussion_id = ?1 AND n.is_system = 0 - ORDER BY n.created_at ASC, n.id ASC", - )?; - - struct NoteRow { - author: Option, - body: Option, - created_at: i64, - gitlab_id: i64, - old_path: Option, - new_path: Option, - } - - let notes: Vec = note_stmt - .query_map(rusqlite::params![id], |row| { - Ok(NoteRow { - author: row.get(0)?, - body: row.get(1)?, - created_at: row.get(2)?, - gitlab_id: row.get(3)?, - old_path: row.get(5)?, - new_path: row.get(6)?, - }) - })? - .collect::, _>>()?; - - if notes.is_empty() { - return Ok(None); - } - - let mut path_set = BTreeSet::new(); - for note in ¬es { - if let Some(ref p) = note.old_path - && !p.is_empty() - { - path_set.insert(p.clone()); - } - if let Some(ref p) = note.new_path - && !p.is_empty() - { - path_set.insert(p.clone()); - } - } - let paths: Vec = path_set.into_iter().collect(); - - let first_note_gitlab_id = notes[0].gitlab_id; - let url = parent_web_url - .as_ref() - .map(|wu| format!("{}#note_{}", wu, first_note_gitlab_id)); - - let author_username = notes[0].author.clone(); - - let display_title = parent_title.as_deref().unwrap_or("(untitled)"); - let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string()); - let paths_json = serde_json::to_string(&paths).unwrap_or_else(|_| "[]".to_string()); - - let mut content = format!( - "[[Discussion]] {}: {}\nProject: {}\n", - parent_type_prefix, display_title, path_with_namespace - ); - if let Some(ref u) = url { - let _ = writeln!(content, "URL: {}", u); - } - let _ = writeln!(content, "Labels: {}", labels_json); - if !paths.is_empty() { - let _ = writeln!(content, "Files: {}", paths_json); - } - - let note_contents: Vec = notes - .iter() - .map(|note| NoteContent { - author: note.author.as_deref().unwrap_or("unknown").to_string(), - date: format_date(note.created_at), - body: note.body.as_deref().unwrap_or("").to_string(), - }) - .collect(); - - let header_len = content.len() + "\n--- Thread ---\n\n".len(); - let thread_budget = MAX_DISCUSSION_BYTES.saturating_sub(header_len); - - let thread_result = truncate_discussion(¬e_contents, thread_budget); - content.push_str("\n--- Thread ---\n\n"); - content.push_str(&thread_result.content); - - let created_at = notes[0].created_at; - let updated_at = notes.last().map(|n| n.created_at).unwrap_or(created_at); - - let content_hash = compute_content_hash(&content); - let labels_hash = compute_list_hash(&labels); - let paths_hash = compute_list_hash(&paths); - - Ok(Some(DocumentData { - source_type: SourceType::Discussion, - source_id: id, - project_id, - author_username, - labels, - paths, - labels_hash, - paths_hash, - created_at, - updated_at, - url, - title: None, - content_text: content, - content_hash, - is_truncated: thread_result.is_truncated, - truncated_reason: thread_result.reason.map(|r| r.as_str().to_string()), - })) -} - -pub fn extract_note_document(conn: &Connection, note_id: i64) -> Result> { - let row = conn.query_row( - "SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, - n.created_at, n.updated_at, n.position_new_path, n.position_new_line, - n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, - d.noteable_type, d.issue_id, d.merge_request_id, - p.path_with_namespace, p.id AS project_id - FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN projects p ON n.project_id = p.id - WHERE n.id = ?1", - rusqlite::params![note_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, bool>(5)?, - row.get::<_, i64>(6)?, - row.get::<_, i64>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, Option>(11)?, - row.get::<_, bool>(12)?, - row.get::<_, bool>(13)?, - row.get::<_, Option>(14)?, - row.get::<_, String>(15)?, - row.get::<_, Option>(16)?, - row.get::<_, Option>(17)?, - row.get::<_, String>(18)?, - row.get::<_, i64>(19)?, - )) - }, - ); - - let ( - _id, - gitlab_id, - author_username, - body, - note_type, - is_system, - created_at, - updated_at, - position_new_path, - position_new_line, - position_old_path, - _position_old_line, - resolvable, - resolved, - _resolved_by, - noteable_type, - issue_id, - merge_request_id, - path_with_namespace, - project_id, - ) = match row { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - - if is_system { - return Ok(None); - } - - let (parent_iid, parent_title, parent_web_url, parent_type_label, labels) = - match noteable_type.as_str() { - "Issue" => { - let parent_id = match issue_id { - Some(pid) => pid, - None => return Ok(None), - }; - let parent = conn.query_row( - "SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1", - rusqlite::params![parent_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (iid, title, web_url) = match parent { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM issue_labels il - JOIN labels l ON l.id = il.label_id - WHERE il.issue_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![parent_id], |row| row.get(0))? - .collect::, _>>()?; - - (iid, title, web_url, "Issue", labels) - } - "MergeRequest" => { - let parent_id = match merge_request_id { - Some(pid) => pid, - None => return Ok(None), - }; - let parent = conn.query_row( - "SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1", - rusqlite::params![parent_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (iid, title, web_url) = match parent { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM mr_labels ml - JOIN labels l ON l.id = ml.label_id - WHERE ml.merge_request_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![parent_id], |row| row.get(0))? - .collect::, _>>()?; - - (iid, title, web_url, "MergeRequest", labels) - } - _ => return Ok(None), - }; - - build_note_document( - note_id, - gitlab_id, - author_username, - body, - note_type, - created_at, - updated_at, - position_new_path, - position_new_line, - position_old_path, - resolvable, - resolved, - parent_iid, - parent_title.as_deref(), - parent_web_url.as_deref(), - &labels, - parent_type_label, - &path_with_namespace, - project_id, - ) -} - -pub struct ParentMetadata { - pub iid: i64, - pub title: Option, - pub web_url: Option, - pub labels: Vec, - pub project_path: String, -} - -pub struct ParentMetadataCache { - cache: HashMap<(String, i64), Option>, -} - -impl Default for ParentMetadataCache { - fn default() -> Self { - Self::new() - } -} - -impl ParentMetadataCache { - pub fn new() -> Self { - Self { - cache: HashMap::new(), - } - } - - pub fn get_or_fetch( - &mut self, - conn: &Connection, - noteable_type: &str, - parent_id: i64, - project_path: &str, - ) -> Result> { - let key = (noteable_type.to_string(), parent_id); - if !self.cache.contains_key(&key) { - let meta = fetch_parent_metadata(conn, noteable_type, parent_id, project_path)?; - self.cache.insert(key.clone(), meta); - } - Ok(self.cache.get(&key).and_then(|m| m.as_ref())) - } -} - -fn fetch_parent_metadata( - conn: &Connection, - noteable_type: &str, - parent_id: i64, - project_path: &str, -) -> Result> { - match noteable_type { - "Issue" => { - let parent = conn.query_row( - "SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1", - rusqlite::params![parent_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (iid, title, web_url) = match parent { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM issue_labels il - JOIN labels l ON l.id = il.label_id - WHERE il.issue_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![parent_id], |row| row.get(0))? - .collect::, _>>()?; - Ok(Some(ParentMetadata { - iid, - title, - web_url, - labels, - project_path: project_path.to_string(), - })) - } - "MergeRequest" => { - let parent = conn.query_row( - "SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1", - rusqlite::params![parent_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (iid, title, web_url) = match parent { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let mut label_stmt = conn.prepare_cached( - "SELECT l.name FROM mr_labels ml - JOIN labels l ON l.id = ml.label_id - WHERE ml.merge_request_id = ?1 - ORDER BY l.name", - )?; - let labels: Vec = label_stmt - .query_map(rusqlite::params![parent_id], |row| row.get(0))? - .collect::, _>>()?; - Ok(Some(ParentMetadata { - iid, - title, - web_url, - labels, - project_path: project_path.to_string(), - })) - } - _ => Ok(None), - } -} - -pub fn extract_note_document_cached( - conn: &Connection, - note_id: i64, - cache: &mut ParentMetadataCache, -) -> Result> { - let row = conn.query_row( - "SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, - n.created_at, n.updated_at, n.position_new_path, n.position_new_line, - n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, - d.noteable_type, d.issue_id, d.merge_request_id, - p.path_with_namespace, p.id AS project_id - FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN projects p ON n.project_id = p.id - WHERE n.id = ?1", - rusqlite::params![note_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, bool>(5)?, - row.get::<_, i64>(6)?, - row.get::<_, i64>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, Option>(11)?, - row.get::<_, bool>(12)?, - row.get::<_, bool>(13)?, - row.get::<_, Option>(14)?, - row.get::<_, String>(15)?, - row.get::<_, Option>(16)?, - row.get::<_, Option>(17)?, - row.get::<_, String>(18)?, - row.get::<_, i64>(19)?, - )) - }, - ); - - let ( - _id, - gitlab_id, - author_username, - body, - note_type, - is_system, - created_at, - updated_at, - position_new_path, - position_new_line, - position_old_path, - _position_old_line, - resolvable, - resolved, - _resolved_by, - noteable_type, - issue_id, - merge_request_id, - path_with_namespace, - project_id, - ) = match row { - Ok(r) => r, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), - Err(e) => return Err(e.into()), - }; - - if is_system { - return Ok(None); - } - - let parent_id = match noteable_type.as_str() { - "Issue" => match issue_id { - Some(pid) => pid, - None => return Ok(None), - }, - "MergeRequest" => match merge_request_id { - Some(pid) => pid, - None => return Ok(None), - }, - _ => return Ok(None), - }; - - let parent = cache.get_or_fetch(conn, ¬eable_type, parent_id, &path_with_namespace)?; - let parent = match parent { - Some(p) => p, - None => return Ok(None), - }; - - let parent_iid = parent.iid; - let parent_title = parent.title.as_deref(); - let parent_web_url = parent.web_url.as_deref(); - let labels = parent.labels.clone(); - let parent_type_label = noteable_type.as_str(); - - build_note_document( - note_id, - gitlab_id, - author_username, - body, - note_type, - created_at, - updated_at, - position_new_path, - position_new_line, - position_old_path, - resolvable, - resolved, - parent_iid, - parent_title, - parent_web_url, - &labels, - parent_type_label, - &path_with_namespace, - project_id, - ) -} - -#[allow(clippy::too_many_arguments)] -fn build_note_document( - note_id: i64, - gitlab_id: i64, - author_username: Option, - body: Option, - note_type: Option, - created_at: i64, - updated_at: i64, - position_new_path: Option, - position_new_line: Option, - position_old_path: Option, - resolvable: bool, - resolved: bool, - parent_iid: i64, - parent_title: Option<&str>, - parent_web_url: Option<&str>, - labels: &[String], - parent_type_label: &str, - path_with_namespace: &str, - project_id: i64, -) -> Result> { - let mut path_set = BTreeSet::new(); - if let Some(ref p) = position_old_path - && !p.is_empty() - { - path_set.insert(p.clone()); - } - if let Some(ref p) = position_new_path - && !p.is_empty() - { - path_set.insert(p.clone()); - } - let paths: Vec = path_set.into_iter().collect(); - - let url = parent_web_url.map(|wu| format!("{}#note_{}", wu, gitlab_id)); - - let display_title = parent_title.unwrap_or("(untitled)"); - let display_note_type = note_type.as_deref().unwrap_or("Note"); - let display_author = author_username.as_deref().unwrap_or("unknown"); - let parent_prefix = if parent_type_label == "Issue" { - format!("Issue #{}", parent_iid) - } else { - format!("MR !{}", parent_iid) - }; - - let title = format!( - "Note by @{} on {}: {}", - display_author, parent_prefix, display_title - ); - - let labels_csv = labels.join(", "); - - let mut content = String::new(); - let _ = writeln!(content, "[[Note]]"); - let _ = writeln!(content, "source_type: note"); - let _ = writeln!(content, "note_gitlab_id: {}", gitlab_id); - let _ = writeln!(content, "project: {}", path_with_namespace); - let _ = writeln!(content, "parent_type: {}", parent_type_label); - let _ = writeln!(content, "parent_iid: {}", parent_iid); - let _ = writeln!(content, "parent_title: {}", display_title); - let _ = writeln!(content, "note_type: {}", display_note_type); - let _ = writeln!(content, "author: @{}", display_author); - let _ = writeln!(content, "created_at: {}", ms_to_iso(created_at)); - if resolvable { - let _ = writeln!(content, "resolved: {}", resolved); - } - if display_note_type == "DiffNote" - && let Some(ref p) = position_new_path - { - if let Some(line) = position_new_line { - let _ = writeln!(content, "path: {}:{}", p, line); - } else { - let _ = writeln!(content, "path: {}", p); - } - } - if !labels.is_empty() { - let _ = writeln!(content, "labels: {}", labels_csv); - } - if let Some(ref u) = url { - let _ = writeln!(content, "url: {}", u); - } - - content.push_str("\n--- Body ---\n\n"); - content.push_str(body.as_deref().unwrap_or("")); - - let labels_hash = compute_list_hash(labels); - let paths_hash = compute_list_hash(&paths); - - let hard_cap = truncate_hard_cap(&content); - let content_hash = compute_content_hash(&hard_cap.content); - - Ok(Some(DocumentData { - source_type: SourceType::Note, - source_id: note_id, - project_id, - author_username, - labels: labels.to_vec(), - paths, - labels_hash, - paths_hash, - created_at, - updated_at, - url, - title: Some(title), - content_text: hard_cap.content, - content_hash, - is_truncated: hard_cap.is_truncated, - truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()), - })) -} - -#[cfg(test)] -#[path = "extractor_tests.rs"] -mod tests; diff --git a/src/documents/extractor/common.rs b/src/documents/extractor/common.rs new file mode 100644 index 0000000..9620581 --- /dev/null +++ b/src/documents/extractor/common.rs @@ -0,0 +1,80 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceType { + Issue, + MergeRequest, + Discussion, + Note, +} + +impl SourceType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Issue => "issue", + Self::MergeRequest => "merge_request", + Self::Discussion => "discussion", + Self::Note => "note", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "issue" | "issues" => Some(Self::Issue), + "mr" | "mrs" | "merge_request" | "merge_requests" => Some(Self::MergeRequest), + "discussion" | "discussions" => Some(Self::Discussion), + "note" | "notes" => Some(Self::Note), + _ => None, + } + } +} + +impl std::fmt::Display for SourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive(Debug, Clone)] +pub struct DocumentData { + pub source_type: SourceType, + pub source_id: i64, + pub project_id: i64, + pub author_username: Option, + pub labels: Vec, + pub paths: Vec, + pub labels_hash: String, + pub paths_hash: String, + pub created_at: i64, + pub updated_at: i64, + pub url: Option, + pub title: Option, + pub content_text: String, + pub content_hash: String, + pub is_truncated: bool, + pub truncated_reason: Option, +} + +pub fn compute_content_hash(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +pub fn compute_list_hash(items: &[String]) -> String { + let mut indices: Vec = (0..items.len()).collect(); + indices.sort_by(|a, b| items[*a].cmp(&items[*b])); + let mut hasher = Sha256::new(); + for (i, &idx) in indices.iter().enumerate() { + if i > 0 { + hasher.update(b"\n"); + } + hasher.update(items[idx].as_bytes()); + } + format!("{:x}", hasher.finalize()) +} + +fn format_date(ms: i64) -> String { + DateTime::from_timestamp_millis(ms) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} diff --git a/src/documents/extractor/discussions.rs b/src/documents/extractor/discussions.rs new file mode 100644 index 0000000..bbcc408 --- /dev/null +++ b/src/documents/extractor/discussions.rs @@ -0,0 +1,216 @@ +pub fn extract_discussion_document( + conn: &Connection, + discussion_id: i64, +) -> Result> { + let disc_row = conn.query_row( + "SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, + p.path_with_namespace, p.id AS project_id + FROM discussions d + JOIN projects p ON p.id = d.project_id + WHERE d.id = ?1", + rusqlite::params![discussion_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, i64>(5)?, + )) + }, + ); + + let (id, noteable_type, issue_id, merge_request_id, path_with_namespace, project_id) = + match disc_row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + let (_parent_iid, parent_title, parent_web_url, parent_type_prefix, labels) = + match noteable_type.as_str() { + "Issue" => { + let parent_id = match issue_id { + Some(pid) => pid, + None => return Ok(None), + }; + let parent = conn.query_row( + "SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1", + rusqlite::params![parent_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (iid, title, web_url) = match parent { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM issue_labels il + JOIN labels l ON l.id = il.label_id + WHERE il.issue_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![parent_id], |row| row.get(0))? + .collect::, _>>()?; + + (iid, title, web_url, format!("Issue #{}", iid), labels) + } + "MergeRequest" => { + let parent_id = match merge_request_id { + Some(pid) => pid, + None => return Ok(None), + }; + let parent = conn.query_row( + "SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1", + rusqlite::params![parent_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (iid, title, web_url) = match parent { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM mr_labels ml + JOIN labels l ON l.id = ml.label_id + WHERE ml.merge_request_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![parent_id], |row| row.get(0))? + .collect::, _>>()?; + + (iid, title, web_url, format!("MR !{}", iid), labels) + } + _ => return Ok(None), + }; + + let mut note_stmt = conn.prepare_cached( + "SELECT n.author_username, n.body, n.created_at, n.gitlab_id, + n.note_type, n.position_old_path, n.position_new_path + FROM notes n + WHERE n.discussion_id = ?1 AND n.is_system = 0 + ORDER BY n.created_at ASC, n.id ASC", + )?; + + struct NoteRow { + author: Option, + body: Option, + created_at: i64, + gitlab_id: i64, + old_path: Option, + new_path: Option, + } + + let notes: Vec = note_stmt + .query_map(rusqlite::params![id], |row| { + Ok(NoteRow { + author: row.get(0)?, + body: row.get(1)?, + created_at: row.get(2)?, + gitlab_id: row.get(3)?, + old_path: row.get(5)?, + new_path: row.get(6)?, + }) + })? + .collect::, _>>()?; + + if notes.is_empty() { + return Ok(None); + } + + let mut path_set = BTreeSet::new(); + for note in ¬es { + if let Some(ref p) = note.old_path + && !p.is_empty() + { + path_set.insert(p.clone()); + } + if let Some(ref p) = note.new_path + && !p.is_empty() + { + path_set.insert(p.clone()); + } + } + let paths: Vec = path_set.into_iter().collect(); + + let first_note_gitlab_id = notes[0].gitlab_id; + let url = parent_web_url + .as_ref() + .map(|wu| format!("{}#note_{}", wu, first_note_gitlab_id)); + + let author_username = notes[0].author.clone(); + + let display_title = parent_title.as_deref().unwrap_or("(untitled)"); + let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string()); + let paths_json = serde_json::to_string(&paths).unwrap_or_else(|_| "[]".to_string()); + + let mut content = format!( + "[[Discussion]] {}: {}\nProject: {}\n", + parent_type_prefix, display_title, path_with_namespace + ); + if let Some(ref u) = url { + let _ = writeln!(content, "URL: {}", u); + } + let _ = writeln!(content, "Labels: {}", labels_json); + if !paths.is_empty() { + let _ = writeln!(content, "Files: {}", paths_json); + } + + let note_contents: Vec = notes + .iter() + .map(|note| NoteContent { + author: note.author.as_deref().unwrap_or("unknown").to_string(), + date: format_date(note.created_at), + body: note.body.as_deref().unwrap_or("").to_string(), + }) + .collect(); + + let header_len = content.len() + "\n--- Thread ---\n\n".len(); + let thread_budget = MAX_DISCUSSION_BYTES.saturating_sub(header_len); + + let thread_result = truncate_discussion(¬e_contents, thread_budget); + content.push_str("\n--- Thread ---\n\n"); + content.push_str(&thread_result.content); + + let created_at = notes[0].created_at; + let updated_at = notes.last().map(|n| n.created_at).unwrap_or(created_at); + + let content_hash = compute_content_hash(&content); + let labels_hash = compute_list_hash(&labels); + let paths_hash = compute_list_hash(&paths); + + Ok(Some(DocumentData { + source_type: SourceType::Discussion, + source_id: id, + project_id, + author_username, + labels, + paths, + labels_hash, + paths_hash, + created_at, + updated_at, + url, + title: None, + content_text: content, + content_hash, + is_truncated: thread_result.is_truncated, + truncated_reason: thread_result.reason.map(|r| r.as_str().to_string()), + })) +} + diff --git a/src/documents/extractor_tests.rs b/src/documents/extractor/extractor_tests.rs similarity index 100% rename from src/documents/extractor_tests.rs rename to src/documents/extractor/extractor_tests.rs diff --git a/src/documents/extractor/issues.rs b/src/documents/extractor/issues.rs new file mode 100644 index 0000000..972361b --- /dev/null +++ b/src/documents/extractor/issues.rs @@ -0,0 +1,110 @@ +pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result> { + let row = conn.query_row( + "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, + i.created_at, i.updated_at, i.web_url, + p.path_with_namespace, p.id AS project_id + FROM issues i + JOIN projects p ON p.id = i.project_id + WHERE i.id = ?1", + rusqlite::params![issue_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, String>(9)?, + row.get::<_, i64>(10)?, + )) + }, + ); + + let ( + id, + iid, + title, + description, + state, + author_username, + created_at, + updated_at, + web_url, + path_with_namespace, + project_id, + ) = match row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM issue_labels il + JOIN labels l ON l.id = il.label_id + WHERE il.issue_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![id], |row| row.get(0))? + .collect::, _>>()?; + + let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string()); + + let display_title = title.as_deref().unwrap_or("(untitled)"); + let mut content = format!( + "[[Issue]] #{}: {}\nProject: {}\n", + iid, display_title, path_with_namespace + ); + if let Some(ref url) = web_url { + let _ = writeln!(content, "URL: {}", url); + } + let _ = writeln!(content, "Labels: {}", labels_json); + let _ = writeln!(content, "State: {}", state); + if let Some(ref author) = author_username { + let _ = writeln!(content, "Author: @{}", author); + } + + if let Some(ref desc) = description { + content.push_str("\n--- Description ---\n\n"); + // Pre-truncate to avoid unbounded memory allocation for huge descriptions + let pre_trunc = pre_truncate_description(desc, MAX_DOCUMENT_BYTES_HARD); + if pre_trunc.was_truncated { + warn!( + iid, + original_bytes = pre_trunc.original_bytes, + "Issue description truncated (oversized)" + ); + } + content.push_str(&pre_trunc.content); + } + + let labels_hash = compute_list_hash(&labels); + let paths_hash = compute_list_hash(&[]); + + let hard_cap = truncate_hard_cap(&content); + let content_hash = compute_content_hash(&hard_cap.content); + + Ok(Some(DocumentData { + source_type: SourceType::Issue, + source_id: id, + project_id, + author_username, + labels, + paths: Vec::new(), + labels_hash, + paths_hash, + created_at, + updated_at, + url: web_url, + title: Some(display_title.to_string()), + content_text: hard_cap.content, + content_hash, + is_truncated: hard_cap.is_truncated, + truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()), + })) +} + diff --git a/src/documents/extractor/mod.rs b/src/documents/extractor/mod.rs new file mode 100644 index 0000000..fd3f8d2 --- /dev/null +++ b/src/documents/extractor/mod.rs @@ -0,0 +1,24 @@ +use chrono::DateTime; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Write as _; + +use super::truncation::{ + MAX_DISCUSSION_BYTES, MAX_DOCUMENT_BYTES_HARD, NoteContent, pre_truncate_description, + truncate_discussion, truncate_hard_cap, +}; +use crate::core::error::Result; +use crate::core::time::ms_to_iso; +use tracing::warn; + +include!("common.rs"); +include!("issues.rs"); +include!("mrs.rs"); +include!("discussions.rs"); +include!("notes.rs"); + +#[cfg(test)] +#[path = "extractor_tests.rs"] +mod tests; diff --git a/src/documents/extractor/mrs.rs b/src/documents/extractor/mrs.rs new file mode 100644 index 0000000..d0c4f9b --- /dev/null +++ b/src/documents/extractor/mrs.rs @@ -0,0 +1,119 @@ +pub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result> { + let row = conn.query_row( + "SELECT m.id, m.iid, m.title, m.description, m.state, m.author_username, + m.source_branch, m.target_branch, + m.created_at, m.updated_at, m.web_url, + p.path_with_namespace, p.id AS project_id + FROM merge_requests m + JOIN projects p ON p.id = m.project_id + WHERE m.id = ?1", + rusqlite::params![mr_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, String>(11)?, + row.get::<_, i64>(12)?, + )) + }, + ); + + let ( + id, + iid, + title, + description, + state, + author_username, + source_branch, + target_branch, + created_at, + updated_at, + web_url, + path_with_namespace, + project_id, + ) = match row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM mr_labels ml + JOIN labels l ON l.id = ml.label_id + WHERE ml.merge_request_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![id], |row| row.get(0))? + .collect::, _>>()?; + + let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string()); + + let display_title = title.as_deref().unwrap_or("(untitled)"); + let display_state = state.as_deref().unwrap_or("unknown"); + let mut content = format!( + "[[MergeRequest]] !{}: {}\nProject: {}\n", + iid, display_title, path_with_namespace + ); + if let Some(ref url) = web_url { + let _ = writeln!(content, "URL: {}", url); + } + let _ = writeln!(content, "Labels: {}", labels_json); + let _ = writeln!(content, "State: {}", display_state); + if let Some(ref author) = author_username { + let _ = writeln!(content, "Author: @{}", author); + } + if let (Some(src), Some(tgt)) = (&source_branch, &target_branch) { + let _ = writeln!(content, "Source: {} -> {}", src, tgt); + } + + if let Some(ref desc) = description { + content.push_str("\n--- Description ---\n\n"); + // Pre-truncate to avoid unbounded memory allocation for huge descriptions + let pre_trunc = pre_truncate_description(desc, MAX_DOCUMENT_BYTES_HARD); + if pre_trunc.was_truncated { + warn!( + iid, + original_bytes = pre_trunc.original_bytes, + "MR description truncated (oversized)" + ); + } + content.push_str(&pre_trunc.content); + } + + let labels_hash = compute_list_hash(&labels); + let paths_hash = compute_list_hash(&[]); + + let hard_cap = truncate_hard_cap(&content); + let content_hash = compute_content_hash(&hard_cap.content); + + Ok(Some(DocumentData { + source_type: SourceType::MergeRequest, + source_id: id, + project_id, + author_username, + labels, + paths: Vec::new(), + labels_hash, + paths_hash, + created_at: created_at.unwrap_or(0), + updated_at: updated_at.unwrap_or(0), + url: web_url, + title: Some(display_title.to_string()), + content_text: hard_cap.content, + content_hash, + is_truncated: hard_cap.is_truncated, + truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()), + })) +} + diff --git a/src/documents/extractor/notes.rs b/src/documents/extractor/notes.rs new file mode 100644 index 0000000..d348261 --- /dev/null +++ b/src/documents/extractor/notes.rs @@ -0,0 +1,514 @@ +pub fn extract_note_document(conn: &Connection, note_id: i64) -> Result> { + let row = conn.query_row( + "SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, + n.created_at, n.updated_at, n.position_new_path, n.position_new_line, + n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, + d.noteable_type, d.issue_id, d.merge_request_id, + p.path_with_namespace, p.id AS project_id + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + JOIN projects p ON n.project_id = p.id + WHERE n.id = ?1", + rusqlite::params![note_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, bool>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, bool>(12)?, + row.get::<_, bool>(13)?, + row.get::<_, Option>(14)?, + row.get::<_, String>(15)?, + row.get::<_, Option>(16)?, + row.get::<_, Option>(17)?, + row.get::<_, String>(18)?, + row.get::<_, i64>(19)?, + )) + }, + ); + + let ( + _id, + gitlab_id, + author_username, + body, + note_type, + is_system, + created_at, + updated_at, + position_new_path, + position_new_line, + position_old_path, + _position_old_line, + resolvable, + resolved, + _resolved_by, + noteable_type, + issue_id, + merge_request_id, + path_with_namespace, + project_id, + ) = match row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + if is_system { + return Ok(None); + } + + let (parent_iid, parent_title, parent_web_url, parent_type_label, labels) = + match noteable_type.as_str() { + "Issue" => { + let parent_id = match issue_id { + Some(pid) => pid, + None => return Ok(None), + }; + let parent = conn.query_row( + "SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1", + rusqlite::params![parent_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (iid, title, web_url) = match parent { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM issue_labels il + JOIN labels l ON l.id = il.label_id + WHERE il.issue_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![parent_id], |row| row.get(0))? + .collect::, _>>()?; + + (iid, title, web_url, "Issue", labels) + } + "MergeRequest" => { + let parent_id = match merge_request_id { + Some(pid) => pid, + None => return Ok(None), + }; + let parent = conn.query_row( + "SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1", + rusqlite::params![parent_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (iid, title, web_url) = match parent { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM mr_labels ml + JOIN labels l ON l.id = ml.label_id + WHERE ml.merge_request_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![parent_id], |row| row.get(0))? + .collect::, _>>()?; + + (iid, title, web_url, "MergeRequest", labels) + } + _ => return Ok(None), + }; + + build_note_document( + note_id, + gitlab_id, + author_username, + body, + note_type, + created_at, + updated_at, + position_new_path, + position_new_line, + position_old_path, + resolvable, + resolved, + parent_iid, + parent_title.as_deref(), + parent_web_url.as_deref(), + &labels, + parent_type_label, + &path_with_namespace, + project_id, + ) +} + +pub struct ParentMetadata { + pub iid: i64, + pub title: Option, + pub web_url: Option, + pub labels: Vec, + pub project_path: String, +} + +pub struct ParentMetadataCache { + cache: HashMap<(String, i64), Option>, +} + +impl Default for ParentMetadataCache { + fn default() -> Self { + Self::new() + } +} + +impl ParentMetadataCache { + pub fn new() -> Self { + Self { + cache: HashMap::new(), + } + } + + pub fn get_or_fetch( + &mut self, + conn: &Connection, + noteable_type: &str, + parent_id: i64, + project_path: &str, + ) -> Result> { + let key = (noteable_type.to_string(), parent_id); + if !self.cache.contains_key(&key) { + let meta = fetch_parent_metadata(conn, noteable_type, parent_id, project_path)?; + self.cache.insert(key.clone(), meta); + } + Ok(self.cache.get(&key).and_then(|m| m.as_ref())) + } +} + +fn fetch_parent_metadata( + conn: &Connection, + noteable_type: &str, + parent_id: i64, + project_path: &str, +) -> Result> { + match noteable_type { + "Issue" => { + let parent = conn.query_row( + "SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1", + rusqlite::params![parent_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (iid, title, web_url) = match parent { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM issue_labels il + JOIN labels l ON l.id = il.label_id + WHERE il.issue_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![parent_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(Some(ParentMetadata { + iid, + title, + web_url, + labels, + project_path: project_path.to_string(), + })) + } + "MergeRequest" => { + let parent = conn.query_row( + "SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1", + rusqlite::params![parent_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (iid, title, web_url) = match parent { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut label_stmt = conn.prepare_cached( + "SELECT l.name FROM mr_labels ml + JOIN labels l ON l.id = ml.label_id + WHERE ml.merge_request_id = ?1 + ORDER BY l.name", + )?; + let labels: Vec = label_stmt + .query_map(rusqlite::params![parent_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(Some(ParentMetadata { + iid, + title, + web_url, + labels, + project_path: project_path.to_string(), + })) + } + _ => Ok(None), + } +} + +pub fn extract_note_document_cached( + conn: &Connection, + note_id: i64, + cache: &mut ParentMetadataCache, +) -> Result> { + let row = conn.query_row( + "SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, + n.created_at, n.updated_at, n.position_new_path, n.position_new_line, + n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, + d.noteable_type, d.issue_id, d.merge_request_id, + p.path_with_namespace, p.id AS project_id + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + JOIN projects p ON n.project_id = p.id + WHERE n.id = ?1", + rusqlite::params![note_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, bool>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, bool>(12)?, + row.get::<_, bool>(13)?, + row.get::<_, Option>(14)?, + row.get::<_, String>(15)?, + row.get::<_, Option>(16)?, + row.get::<_, Option>(17)?, + row.get::<_, String>(18)?, + row.get::<_, i64>(19)?, + )) + }, + ); + + let ( + _id, + gitlab_id, + author_username, + body, + note_type, + is_system, + created_at, + updated_at, + position_new_path, + position_new_line, + position_old_path, + _position_old_line, + resolvable, + resolved, + _resolved_by, + noteable_type, + issue_id, + merge_request_id, + path_with_namespace, + project_id, + ) = match row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + if is_system { + return Ok(None); + } + + let parent_id = match noteable_type.as_str() { + "Issue" => match issue_id { + Some(pid) => pid, + None => return Ok(None), + }, + "MergeRequest" => match merge_request_id { + Some(pid) => pid, + None => return Ok(None), + }, + _ => return Ok(None), + }; + + let parent = cache.get_or_fetch(conn, ¬eable_type, parent_id, &path_with_namespace)?; + let parent = match parent { + Some(p) => p, + None => return Ok(None), + }; + + let parent_iid = parent.iid; + let parent_title = parent.title.as_deref(); + let parent_web_url = parent.web_url.as_deref(); + let labels = parent.labels.clone(); + let parent_type_label = noteable_type.as_str(); + + build_note_document( + note_id, + gitlab_id, + author_username, + body, + note_type, + created_at, + updated_at, + position_new_path, + position_new_line, + position_old_path, + resolvable, + resolved, + parent_iid, + parent_title, + parent_web_url, + &labels, + parent_type_label, + &path_with_namespace, + project_id, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_note_document( + note_id: i64, + gitlab_id: i64, + author_username: Option, + body: Option, + note_type: Option, + created_at: i64, + updated_at: i64, + position_new_path: Option, + position_new_line: Option, + position_old_path: Option, + resolvable: bool, + resolved: bool, + parent_iid: i64, + parent_title: Option<&str>, + parent_web_url: Option<&str>, + labels: &[String], + parent_type_label: &str, + path_with_namespace: &str, + project_id: i64, +) -> Result> { + let mut path_set = BTreeSet::new(); + if let Some(ref p) = position_old_path + && !p.is_empty() + { + path_set.insert(p.clone()); + } + if let Some(ref p) = position_new_path + && !p.is_empty() + { + path_set.insert(p.clone()); + } + let paths: Vec = path_set.into_iter().collect(); + + let url = parent_web_url.map(|wu| format!("{}#note_{}", wu, gitlab_id)); + + let display_title = parent_title.unwrap_or("(untitled)"); + let display_note_type = note_type.as_deref().unwrap_or("Note"); + let display_author = author_username.as_deref().unwrap_or("unknown"); + let parent_prefix = if parent_type_label == "Issue" { + format!("Issue #{}", parent_iid) + } else { + format!("MR !{}", parent_iid) + }; + + let title = format!( + "Note by @{} on {}: {}", + display_author, parent_prefix, display_title + ); + + let labels_csv = labels.join(", "); + + let mut content = String::new(); + let _ = writeln!(content, "[[Note]]"); + let _ = writeln!(content, "source_type: note"); + let _ = writeln!(content, "note_gitlab_id: {}", gitlab_id); + let _ = writeln!(content, "project: {}", path_with_namespace); + let _ = writeln!(content, "parent_type: {}", parent_type_label); + let _ = writeln!(content, "parent_iid: {}", parent_iid); + let _ = writeln!(content, "parent_title: {}", display_title); + let _ = writeln!(content, "note_type: {}", display_note_type); + let _ = writeln!(content, "author: @{}", display_author); + let _ = writeln!(content, "created_at: {}", ms_to_iso(created_at)); + if resolvable { + let _ = writeln!(content, "resolved: {}", resolved); + } + if display_note_type == "DiffNote" + && let Some(ref p) = position_new_path + { + if let Some(line) = position_new_line { + let _ = writeln!(content, "path: {}:{}", p, line); + } else { + let _ = writeln!(content, "path: {}", p); + } + } + if !labels.is_empty() { + let _ = writeln!(content, "labels: {}", labels_csv); + } + if let Some(ref u) = url { + let _ = writeln!(content, "url: {}", u); + } + + content.push_str("\n--- Body ---\n\n"); + content.push_str(body.as_deref().unwrap_or("")); + + let labels_hash = compute_list_hash(labels); + let paths_hash = compute_list_hash(&paths); + + let hard_cap = truncate_hard_cap(&content); + let content_hash = compute_content_hash(&hard_cap.content); + + Ok(Some(DocumentData { + source_type: SourceType::Note, + source_id: note_id, + project_id, + author_username, + labels: labels.to_vec(), + paths, + labels_hash, + paths_hash, + created_at, + updated_at, + url, + title: Some(title), + content_text: hard_cap.content, + content_hash, + is_truncated: hard_cap.is_truncated, + truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()), + })) +} diff --git a/src/embedding/change_detector.rs b/src/embedding/change_detector.rs index fc7eb5c..e3296af 100644 --- a/src/embedding/change_detector.rs +++ b/src/embedding/change_detector.rs @@ -1,7 +1,7 @@ use rusqlite::Connection; use crate::core::error::Result; -use crate::embedding::chunking::{CHUNK_MAX_BYTES, EXPECTED_DIMS}; +use crate::embedding::chunks::{CHUNK_MAX_BYTES, EXPECTED_DIMS}; #[derive(Debug)] pub struct PendingDocument { diff --git a/src/embedding/chunks.rs b/src/embedding/chunks.rs new file mode 100644 index 0000000..c4add8d --- /dev/null +++ b/src/embedding/chunks.rs @@ -0,0 +1,177 @@ +pub const CHUNK_ROWID_MULTIPLIER: i64 = 1000; + +pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 { + assert!( + (0..CHUNK_ROWID_MULTIPLIER).contains(&chunk_index), + "chunk_index {chunk_index} out of range [0, {CHUNK_ROWID_MULTIPLIER})" + ); + document_id + .checked_mul(CHUNK_ROWID_MULTIPLIER) + .and_then(|v| v.checked_add(chunk_index)) + .unwrap_or_else(|| { + panic!("encode_rowid overflow: document_id={document_id}, chunk_index={chunk_index}") + }) +} + +pub fn decode_rowid(rowid: i64) -> (i64, i64) { + assert!( + rowid >= 0, + "decode_rowid called with negative rowid: {rowid}" + ); + let document_id = rowid / CHUNK_ROWID_MULTIPLIER; + let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER; + (document_id, chunk_index) +} + +#[cfg(test)] +mod chunk_ids_tests { + use super::*; + + #[test] + fn test_encode_single_chunk() { + assert_eq!(encode_rowid(1, 0), 1000); + } + + #[test] + fn test_encode_multi_chunk() { + assert_eq!(encode_rowid(1, 5), 1005); + } + + #[test] + fn test_encode_specific_values() { + assert_eq!(encode_rowid(42, 0), 42000); + assert_eq!(encode_rowid(42, 5), 42005); + } + + #[test] + fn test_decode_zero_chunk() { + assert_eq!(decode_rowid(42000), (42, 0)); + } + + #[test] + fn test_decode_roundtrip() { + for doc_id in [0, 1, 42, 100, 999, 10000] { + for chunk_idx in [0, 1, 5, 99, 999] { + let rowid = encode_rowid(doc_id, chunk_idx); + let (decoded_doc, decoded_chunk) = decode_rowid(rowid); + assert_eq!( + (decoded_doc, decoded_chunk), + (doc_id, chunk_idx), + "Roundtrip failed for doc_id={doc_id}, chunk_idx={chunk_idx}" + ); + } + } + } + + #[test] + fn test_multiplier_value() { + assert_eq!(CHUNK_ROWID_MULTIPLIER, 1000); + } +} +pub const CHUNK_MAX_BYTES: usize = 1_500; + +pub const EXPECTED_DIMS: usize = 768; + +pub const CHUNK_OVERLAP_CHARS: usize = 200; + +pub fn split_into_chunks(content: &str) -> Vec<(usize, String)> { + if content.is_empty() { + return Vec::new(); + } + + if content.len() <= CHUNK_MAX_BYTES { + return vec![(0, content.to_string())]; + } + + let mut chunks: Vec<(usize, String)> = Vec::new(); + let mut start = 0; + let mut chunk_index = 0; + + while start < content.len() { + let remaining = &content[start..]; + if remaining.len() <= CHUNK_MAX_BYTES { + chunks.push((chunk_index, remaining.to_string())); + break; + } + + let end = floor_char_boundary(content, start + CHUNK_MAX_BYTES); + let window = &content[start..end]; + + let split_at = find_paragraph_break(window) + .or_else(|| find_sentence_break(window)) + .or_else(|| find_word_break(window)) + .unwrap_or(window.len()); + + let chunk_text = &content[start..start + split_at]; + chunks.push((chunk_index, chunk_text.to_string())); + + let advance = if split_at > CHUNK_OVERLAP_CHARS { + split_at - CHUNK_OVERLAP_CHARS + } else { + split_at + } + .max(1); + let old_start = start; + start += advance; + // Ensure start lands on a char boundary after overlap subtraction + start = floor_char_boundary(content, start); + // Guarantee forward progress: multi-byte chars can cause + // floor_char_boundary to round back to old_start + if start <= old_start { + start = old_start + + content[old_start..] + .chars() + .next() + .map_or(1, |c| c.len_utf8()); + } + chunk_index += 1; + } + + chunks +} + +fn find_paragraph_break(window: &str) -> Option { + let search_start = floor_char_boundary(window, window.len() * 2 / 3); + window[search_start..] + .rfind("\n\n") + .map(|pos| search_start + pos + 2) + .or_else(|| window[..search_start].rfind("\n\n").map(|pos| pos + 2)) +} + +fn find_sentence_break(window: &str) -> Option { + let search_start = floor_char_boundary(window, window.len() / 2); + for pat in &[". ", "? ", "! "] { + if let Some(pos) = window[search_start..].rfind(pat) { + return Some(search_start + pos + pat.len()); + } + } + for pat in &[". ", "? ", "! "] { + if let Some(pos) = window[..search_start].rfind(pat) { + return Some(pos + pat.len()); + } + } + None +} + +fn find_word_break(window: &str) -> Option { + let search_start = floor_char_boundary(window, window.len() / 2); + window[search_start..] + .rfind(' ') + .map(|pos| search_start + pos + 1) + .or_else(|| window[..search_start].rfind(' ').map(|pos| pos + 1)) +} + +fn floor_char_boundary(s: &str, idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + let mut i = idx; + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + +#[cfg(test)] +#[path = "chunking_tests.rs"] +mod chunking_tests; diff --git a/src/embedding/mod.rs b/src/embedding/mod.rs index fd3ac9f..8b19838 100644 --- a/src/embedding/mod.rs +++ b/src/embedding/mod.rs @@ -1,11 +1,10 @@ pub mod change_detector; -pub mod chunk_ids; -pub mod chunking; +pub mod chunks; pub mod ollama; pub mod pipeline; pub mod similarity; pub use change_detector::{PendingDocument, count_pending_documents, find_pending_documents}; -pub use chunking::{CHUNK_MAX_BYTES, CHUNK_OVERLAP_CHARS, split_into_chunks}; +pub use chunks::{CHUNK_MAX_BYTES, CHUNK_OVERLAP_CHARS, split_into_chunks}; pub use pipeline::{EmbedForIdsResult, EmbedResult, embed_documents, embed_documents_by_ids}; pub use similarity::cosine_similarity; diff --git a/src/embedding/pipeline.rs b/src/embedding/pipeline.rs index ba37d2d..3c166fc 100644 --- a/src/embedding/pipeline.rs +++ b/src/embedding/pipeline.rs @@ -9,8 +9,9 @@ use tracing::{debug, info, instrument, warn}; use crate::core::error::Result; use crate::core::shutdown::ShutdownSignal; use crate::embedding::change_detector::{count_pending_documents, find_pending_documents}; -use crate::embedding::chunk_ids::{CHUNK_ROWID_MULTIPLIER, encode_rowid}; -use crate::embedding::chunking::{CHUNK_MAX_BYTES, EXPECTED_DIMS, split_into_chunks}; +use crate::embedding::chunks::{ + CHUNK_MAX_BYTES, CHUNK_ROWID_MULTIPLIER, EXPECTED_DIMS, encode_rowid, split_into_chunks, +}; use crate::embedding::ollama::OllamaClient; const BATCH_SIZE: usize = 32; @@ -685,7 +686,7 @@ fn find_documents_by_ids( document_ids: &[i64], model_name: &str, ) -> Result> { - use crate::embedding::chunking::{CHUNK_MAX_BYTES, EXPECTED_DIMS}; + use crate::embedding::chunks::{CHUNK_MAX_BYTES, EXPECTED_DIMS}; if document_ids.is_empty() { return Ok(Vec::new()); diff --git a/src/embedding/pipeline_tests.rs b/src/embedding/pipeline_tests.rs index 08e272c..9361dec 100644 --- a/src/embedding/pipeline_tests.rs +++ b/src/embedding/pipeline_tests.rs @@ -6,7 +6,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::core::db::{create_connection, run_migrations}; use crate::core::shutdown::ShutdownSignal; -use crate::embedding::chunking::EXPECTED_DIMS; +use crate::embedding::chunks::EXPECTED_DIMS; use crate::embedding::ollama::{OllamaClient, OllamaConfig}; use crate::embedding::pipeline::embed_documents_by_ids; diff --git a/src/ingestion/discussions.rs b/src/ingestion/discussions.rs index c0f99f6..20e827b 100644 --- a/src/ingestion/discussions.rs +++ b/src/ingestion/discussions.rs @@ -3,7 +3,6 @@ use tracing::{debug, warn}; use crate::Config; use crate::core::error::Result; -use crate::core::payloads::{StorePayloadOptions, store_payload}; use crate::core::time::now_ms; use crate::documents::SourceType; use crate::gitlab::GitLabClient; @@ -12,6 +11,7 @@ use crate::gitlab::transformers::{ }; use crate::gitlab::types::GitLabDiscussion; use crate::ingestion::dirty_tracker; +use crate::ingestion::storage::payloads::{StorePayloadOptions, store_payload}; use super::issues::IssueForDiscussionSync; diff --git a/src/ingestion/issues.rs b/src/ingestion/issues.rs index 7b7b29e..949416b 100644 --- a/src/ingestion/issues.rs +++ b/src/ingestion/issues.rs @@ -6,7 +6,6 @@ use tracing::{debug, warn}; use crate::Config; use crate::core::error::{LoreError, Result}; -use crate::core::payloads::{StorePayloadOptions, store_payload}; use crate::core::shutdown::ShutdownSignal; use crate::core::time::now_ms; use crate::documents::SourceType; @@ -14,6 +13,7 @@ use crate::gitlab::GitLabClient; use crate::gitlab::transformers::{MilestoneRow, transform_issue}; use crate::gitlab::types::GitLabIssue; use crate::ingestion::dirty_tracker; +use crate::ingestion::storage::payloads::{StorePayloadOptions, store_payload}; #[derive(Debug, Default)] pub struct IngestIssuesResult { diff --git a/src/ingestion/merge_requests.rs b/src/ingestion/merge_requests.rs index 94d51ab..f93f587 100644 --- a/src/ingestion/merge_requests.rs +++ b/src/ingestion/merge_requests.rs @@ -5,7 +5,6 @@ use tracing::{debug, warn}; use crate::Config; use crate::core::error::{LoreError, Result}; -use crate::core::payloads::{StorePayloadOptions, store_payload}; use crate::core::shutdown::ShutdownSignal; use crate::core::time::now_ms; use crate::documents::SourceType; @@ -13,6 +12,7 @@ use crate::gitlab::GitLabClient; use crate::gitlab::transformers::merge_request::transform_merge_request; use crate::gitlab::types::GitLabMergeRequest; use crate::ingestion::dirty_tracker; +use crate::ingestion::storage::payloads::{StorePayloadOptions, store_payload}; #[derive(Debug, Default)] pub struct IngestMergeRequestsResult { diff --git a/src/ingestion/mod.rs b/src/ingestion/mod.rs index 228defc..775cf8a 100644 --- a/src/ingestion/mod.rs +++ b/src/ingestion/mod.rs @@ -6,6 +6,7 @@ pub mod merge_requests; pub mod mr_diffs; pub mod mr_discussions; pub mod orchestrator; +pub mod storage; pub(crate) mod surgical; pub use discussions::{ diff --git a/src/ingestion/mr_discussions.rs b/src/ingestion/mr_discussions.rs index 736f301..6c98047 100644 --- a/src/ingestion/mr_discussions.rs +++ b/src/ingestion/mr_discussions.rs @@ -4,7 +4,6 @@ use tracing::{debug, info, warn}; use crate::Config; use crate::core::error::Result; -use crate::core::payloads::{StorePayloadOptions, store_payload}; use crate::core::time::now_ms; use crate::documents::SourceType; use crate::gitlab::GitLabClient; @@ -15,6 +14,7 @@ use crate::gitlab::transformers::{ use crate::gitlab::types::GitLabDiscussion; use crate::ingestion::dirty_tracker; use crate::ingestion::discussions::NoteUpsertOutcome; +use crate::ingestion::storage::payloads::{StorePayloadOptions, store_payload}; use super::merge_requests::MrForDiscussionSync; diff --git a/src/ingestion/orchestrator.rs b/src/ingestion/orchestrator.rs index 7ddd116..41bf068 100644 --- a/src/ingestion/orchestrator.rs +++ b/src/ingestion/orchestrator.rs @@ -3,15 +3,15 @@ use rusqlite::Connection; use tracing::{debug, instrument, warn}; use crate::Config; -use crate::core::dependent_queue::{ - claim_jobs, complete_job_tx, count_claimable_jobs, enqueue_job, fail_job, reclaim_stale_locks, -}; use crate::core::error::Result; -use crate::core::references::{ - EntityReference, insert_entity_reference, resolve_issue_local_id, resolve_project_path, -}; use crate::core::shutdown::ShutdownSignal; use crate::gitlab::GitLabClient; +use crate::ingestion::storage::queue::{ + claim_jobs, complete_job_tx, count_claimable_jobs, enqueue_job, fail_job, reclaim_stale_locks, +}; +use crate::xref::references::{ + EntityReference, insert_entity_reference, resolve_issue_local_id, resolve_project_path, +}; use super::discussions::{prefetch_issue_discussions, write_prefetched_issue_discussions}; use super::issues::{IssueForDiscussionSync, ingest_issues}; @@ -354,7 +354,7 @@ pub async fn ingest_project_issues_with_progress( result.resource_events_failed = drain_result.failed; let refs_inserted = - crate::core::references::extract_refs_from_state_events(conn, project_id)?; + crate::xref::references::extract_refs_from_state_events(conn, project_id)?; if refs_inserted > 0 { debug!( refs_inserted, @@ -654,7 +654,7 @@ pub async fn ingest_project_merge_requests_with_progress( result.resource_events_failed = drain_result.failed; let refs_inserted = - crate::core::references::extract_refs_from_state_events(conn, project_id)?; + crate::xref::references::extract_refs_from_state_events(conn, project_id)?; if refs_inserted > 0 { debug!( refs_inserted, @@ -668,7 +668,7 @@ pub async fn ingest_project_merge_requests_with_progress( return Ok(result); } - let note_refs = crate::core::note_parser::extract_refs_from_system_notes(conn, project_id)?; + let note_refs = crate::xref::note_parser::extract_refs_from_system_notes(conn, project_id)?; if note_refs.inserted > 0 || note_refs.skipped_unresolvable > 0 { debug!( inserted = note_refs.inserted, @@ -678,7 +678,7 @@ pub async fn ingest_project_merge_requests_with_progress( ); } - let desc_refs = crate::core::note_parser::extract_refs_from_descriptions(conn, project_id)?; + let desc_refs = crate::xref::note_parser::extract_refs_from_descriptions(conn, project_id)?; if desc_refs.inserted > 0 || desc_refs.skipped_unresolvable > 0 { debug!( inserted = desc_refs.inserted, @@ -687,7 +687,7 @@ pub async fn ingest_project_merge_requests_with_progress( ); } - let user_note_refs = crate::core::note_parser::extract_refs_from_user_notes(conn, project_id)?; + let user_note_refs = crate::xref::note_parser::extract_refs_from_user_notes(conn, project_id)?; if user_note_refs.inserted > 0 || user_note_refs.skipped_unresolvable > 0 { debug!( inserted = user_note_refs.inserted, @@ -1121,7 +1121,7 @@ pub(crate) fn store_resource_events( milestone_events: &[crate::gitlab::types::GitLabMilestoneEvent], ) -> Result<()> { if !state_events.is_empty() { - crate::core::events_db::upsert_state_events( + crate::ingestion::storage::events::upsert_state_events( conn, project_id, entity_type, @@ -1131,7 +1131,7 @@ pub(crate) fn store_resource_events( } if !label_events.is_empty() { - crate::core::events_db::upsert_label_events( + crate::ingestion::storage::events::upsert_label_events( conn, project_id, entity_type, @@ -1141,7 +1141,7 @@ pub(crate) fn store_resource_events( } if !milestone_events.is_empty() { - crate::core::events_db::upsert_milestone_events( + crate::ingestion::storage::events::upsert_milestone_events( conn, project_id, entity_type, diff --git a/src/core/events_db.rs b/src/ingestion/storage/events.rs similarity index 98% rename from src/core/events_db.rs rename to src/ingestion/storage/events.rs index be77fdb..b786b0e 100644 --- a/src/core/events_db.rs +++ b/src/ingestion/storage/events.rs @@ -1,7 +1,7 @@ use rusqlite::Connection; -use super::error::{LoreError, Result}; -use super::time::iso_to_ms_strict; +use crate::core::error::{LoreError, Result}; +use crate::core::time::iso_to_ms_strict; use crate::gitlab::types::{GitLabLabelEvent, GitLabMilestoneEvent, GitLabStateEvent}; pub fn upsert_state_events( diff --git a/src/ingestion/storage/mod.rs b/src/ingestion/storage/mod.rs new file mode 100644 index 0000000..d59bb10 --- /dev/null +++ b/src/ingestion/storage/mod.rs @@ -0,0 +1,4 @@ +pub mod events; +pub mod payloads; +pub mod queue; +pub mod sync_run; diff --git a/src/core/payloads.rs b/src/ingestion/storage/payloads.rs similarity index 97% rename from src/core/payloads.rs rename to src/ingestion/storage/payloads.rs index 6963f7e..c5e62ab 100644 --- a/src/core/payloads.rs +++ b/src/ingestion/storage/payloads.rs @@ -6,8 +6,8 @@ use rusqlite::OptionalExtension; use sha2::{Digest, Sha256}; use std::io::{Read, Write}; -use super::error::Result; -use super::time::now_ms; +use crate::core::error::Result; +use crate::core::time::now_ms; pub struct StorePayloadOptions<'a> { pub project_id: Option, diff --git a/src/core/payloads_tests.rs b/src/ingestion/storage/payloads_tests.rs similarity index 100% rename from src/core/payloads_tests.rs rename to src/ingestion/storage/payloads_tests.rs diff --git a/src/core/dependent_queue.rs b/src/ingestion/storage/queue.rs similarity index 98% rename from src/core/dependent_queue.rs rename to src/ingestion/storage/queue.rs index be0ed82..3746774 100644 --- a/src/core/dependent_queue.rs +++ b/src/ingestion/storage/queue.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use rusqlite::Connection; -use super::error::Result; -use super::time::now_ms; +use crate::core::error::{LoreError, Result}; +use crate::core::time::now_ms; #[derive(Debug)] pub struct PendingJob { @@ -139,7 +139,7 @@ pub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()> { )?; if changes == 0 { - return Err(crate::core::error::LoreError::Other( + return Err(LoreError::Other( "fail_job: job not found (may have been reclaimed or completed)".into(), )); } diff --git a/src/core/sync_run.rs b/src/ingestion/storage/sync_run.rs similarity index 97% rename from src/core/sync_run.rs rename to src/ingestion/storage/sync_run.rs index ab135df..d4ba0ab 100644 --- a/src/core/sync_run.rs +++ b/src/ingestion/storage/sync_run.rs @@ -1,8 +1,8 @@ use rusqlite::Connection; -use super::error::Result; -use super::metrics::StageTiming; -use super::time::now_ms; +use crate::core::error::Result; +use crate::core::metrics::StageTiming; +use crate::core::time::now_ms; pub struct SyncRunRecorder { row_id: i64, diff --git a/src/core/sync_run_tests.rs b/src/ingestion/storage/sync_run_tests.rs similarity index 100% rename from src/core/sync_run_tests.rs rename to src/ingestion/storage/sync_run_tests.rs diff --git a/src/lib.rs b/src/lib.rs index a271c48..a7aa762 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,9 @@ pub mod embedding; pub mod gitlab; pub mod ingestion; pub mod search; +#[cfg(test)] +pub mod test_support; +pub mod timeline; +pub mod xref; pub use core::{Config, LoreError, Result}; diff --git a/src/search/vector.rs b/src/search/vector.rs index 1d307fe..78483d1 100644 --- a/src/search/vector.rs +++ b/src/search/vector.rs @@ -4,7 +4,7 @@ use rusqlite::Connection; use rusqlite::OptionalExtension; use crate::core::error::Result; -use crate::embedding::chunk_ids::decode_rowid; +use crate::embedding::chunks::decode_rowid; #[derive(Debug)] pub struct VectorResult { diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..e3f2221 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,49 @@ +use std::path::Path; + +use rusqlite::Connection; + +use crate::core::config::{ + Config, EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, + StorageConfig, SyncConfig, +}; +use crate::core::db::{create_connection, run_migrations}; + +pub fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +pub fn insert_project(conn: &Connection, id: i64, path: &str) { + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + id, + id * 100, + path, + format!("https://git.example.com/{path}") + ], + ) + .unwrap(); +} + +pub fn test_config(default_project: Option<&str>) -> Config { + Config { + gitlab: GitLabConfig { + base_url: "https://gitlab.example.com".to_string(), + token_env_var: "GITLAB_TOKEN".to_string(), + token: None, + username: None, + }, + projects: vec![ProjectConfig { + path: "group/project".to_string(), + }], + default_project: default_project.map(String::from), + sync: SyncConfig::default(), + storage: StorageConfig::default(), + embedding: EmbeddingConfig::default(), + logging: LoggingConfig::default(), + scoring: ScoringConfig::default(), + } +} diff --git a/src/core/timeline_collect.rs b/src/timeline/collect.rs similarity index 99% rename from src/core/timeline_collect.rs rename to src/timeline/collect.rs index 7db1b66..3c6837d 100644 --- a/src/core/timeline_collect.rs +++ b/src/timeline/collect.rs @@ -2,11 +2,11 @@ use rusqlite::Connection; use std::collections::HashSet; -use crate::core::error::{LoreError, Result}; -use crate::core::timeline::{ +use super::types::{ EntityRef, ExpandedEntityRef, MatchedDiscussion, THREAD_MAX_NOTES, THREAD_NOTE_MAX_CHARS, ThreadNote, TimelineEvent, TimelineEventType, truncate_to_chars, }; +use crate::core::error::{LoreError, Result}; /// Collect all events for seed and expanded entities, interleave chronologically. /// diff --git a/src/core/timeline_expand.rs b/src/timeline/expand.rs similarity index 98% rename from src/core/timeline_expand.rs rename to src/timeline/expand.rs index 5b81d39..4fa18c2 100644 --- a/src/core/timeline_expand.rs +++ b/src/timeline/expand.rs @@ -2,8 +2,8 @@ use std::collections::{HashSet, VecDeque}; use rusqlite::Connection; +use super::types::{EntityRef, ExpandedEntityRef, UnresolvedRef, resolve_entity_ref}; use crate::core::error::Result; -use crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef, resolve_entity_ref}; /// Result of the expand phase. pub struct ExpandResult { diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs new file mode 100644 index 0000000..2f9e4a6 --- /dev/null +++ b/src/timeline/mod.rs @@ -0,0 +1,11 @@ +mod types; + +pub mod collect; +pub mod expand; +pub mod seed; + +pub use types::{ + EntityRef, ExpandedEntityRef, MatchedDiscussion, THREAD_MAX_NOTES, THREAD_NOTE_MAX_CHARS, + ThreadNote, TimelineEvent, TimelineEventType, TimelineResult, UnresolvedRef, + resolve_entity_by_iid, resolve_entity_ref, +}; diff --git a/src/core/timeline_seed.rs b/src/timeline/seed.rs similarity index 99% rename from src/core/timeline_seed.rs rename to src/timeline/seed.rs index 9a22f86..caa44d0 100644 --- a/src/core/timeline_seed.rs +++ b/src/timeline/seed.rs @@ -3,11 +3,11 @@ use std::collections::HashSet; use rusqlite::Connection; use tracing::debug; -use crate::core::error::Result; -use crate::core::timeline::{ +use super::types::{ EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_by_iid, resolve_entity_ref, truncate_to_chars, }; +use crate::core::error::Result; use crate::embedding::ollama::OllamaClient; use crate::search::{FtsQueryMode, SearchFilters, SearchMode, search_hybrid, to_fts_query}; diff --git a/src/core/timeline_collect_tests.rs b/src/timeline/timeline_collect_tests.rs similarity index 99% rename from src/core/timeline_collect_tests.rs rename to src/timeline/timeline_collect_tests.rs index 88265eb..6f95d17 100644 --- a/src/core/timeline_collect_tests.rs +++ b/src/timeline/timeline_collect_tests.rs @@ -554,7 +554,7 @@ fn test_collect_discussion_thread_body_truncation() { if let TimelineEventType::DiscussionThread { notes, .. } = &thread.event_type { assert!( - notes[0].body.chars().count() <= crate::core::timeline::THREAD_NOTE_MAX_CHARS, + notes[0].body.chars().count() <= crate::timeline::THREAD_NOTE_MAX_CHARS, "Body should be truncated to THREAD_NOTE_MAX_CHARS" ); } else { @@ -598,7 +598,7 @@ fn test_collect_discussion_thread_note_cap() { // 50 notes + 1 synthetic summary = 51 assert_eq!( notes.len(), - crate::core::timeline::THREAD_MAX_NOTES + 1, + crate::timeline::THREAD_MAX_NOTES + 1, "Should cap at THREAD_MAX_NOTES + synthetic summary" ); let last = notes.last().unwrap(); diff --git a/src/core/timeline_expand_tests.rs b/src/timeline/timeline_expand_tests.rs similarity index 100% rename from src/core/timeline_expand_tests.rs rename to src/timeline/timeline_expand_tests.rs diff --git a/src/core/timeline_seed_tests.rs b/src/timeline/timeline_seed_tests.rs similarity index 100% rename from src/core/timeline_seed_tests.rs rename to src/timeline/timeline_seed_tests.rs diff --git a/src/core/timeline.rs b/src/timeline/types.rs similarity index 98% rename from src/core/timeline.rs rename to src/timeline/types.rs index ee0d214..d899e4c 100644 --- a/src/core/timeline.rs +++ b/src/timeline/types.rs @@ -3,7 +3,7 @@ use std::cmp::Ordering; use rusqlite::Connection; use serde::Serialize; -use super::error::Result; +use crate::core::error::{LoreError, Result}; /// The core timeline event. All pipeline stages produce or consume these. /// Spec ref: Section 3.3 "Event Model" @@ -232,7 +232,7 @@ pub fn resolve_entity_by_iid( "issue" => "issues", "merge_request" => "merge_requests", _ => { - return Err(super::error::LoreError::NotFound(format!( + return Err(LoreError::NotFound(format!( "Unknown entity type: {entity_type}" ))); } @@ -259,7 +259,7 @@ pub fn resolve_entity_by_iid( match rows.len() { 0 => { let sigil = if entity_type == "issue" { "#" } else { "!" }; - Err(super::error::LoreError::NotFound(format!( + Err(LoreError::NotFound(format!( "{entity_type} {sigil}{iid} not found" ))) } @@ -275,7 +275,7 @@ pub fn resolve_entity_by_iid( _ => { let projects: Vec<&str> = rows.iter().map(|(_, _, p)| p.as_str()).collect(); let sigil = if entity_type == "issue" { "#" } else { "!" }; - Err(super::error::LoreError::Ambiguous(format!( + Err(LoreError::Ambiguous(format!( "{entity_type} {sigil}{iid} exists in multiple projects: {}. Use --project to specify.", projects.join(", ") ))) diff --git a/src/xref/mod.rs b/src/xref/mod.rs new file mode 100644 index 0000000..9d1dca0 --- /dev/null +++ b/src/xref/mod.rs @@ -0,0 +1,2 @@ +pub mod note_parser; +pub mod references; diff --git a/src/core/note_parser.rs b/src/xref/note_parser.rs similarity index 99% rename from src/core/note_parser.rs rename to src/xref/note_parser.rs index afafd38..2959025 100644 --- a/src/core/note_parser.rs +++ b/src/xref/note_parser.rs @@ -4,8 +4,8 @@ use regex::Regex; use rusqlite::Connection; use tracing::debug; -use super::error::Result; -use super::time::now_ms; +use crate::core::error::Result; +use crate::core::time::now_ms; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedCrossRef { diff --git a/src/core/note_parser_tests.rs b/src/xref/note_parser_tests.rs similarity index 100% rename from src/core/note_parser_tests.rs rename to src/xref/note_parser_tests.rs diff --git a/src/core/references.rs b/src/xref/references.rs similarity index 98% rename from src/core/references.rs rename to src/xref/references.rs index c55039b..7a9aa6b 100644 --- a/src/core/references.rs +++ b/src/xref/references.rs @@ -1,8 +1,8 @@ use rusqlite::{Connection, OptionalExtension}; use tracing::info; -use super::error::Result; -use super::time::now_ms; +use crate::core::error::Result; +use crate::core::time::now_ms; pub fn extract_refs_from_state_events(conn: &Connection, project_id: i64) -> Result { let changes = conn.execute( diff --git a/src/core/references_tests.rs b/src/xref/references_tests.rs similarity index 100% rename from src/core/references_tests.rs rename to src/xref/references_tests.rs diff --git a/tests/timeline_pipeline_tests.rs b/tests/timeline_pipeline_tests.rs index bbd282a..d840bbb 100644 --- a/tests/timeline_pipeline_tests.rs +++ b/tests/timeline_pipeline_tests.rs @@ -1,8 +1,8 @@ use lore::core::db::{create_connection, run_migrations}; -use lore::core::timeline::{TimelineEventType, resolve_entity_ref}; -use lore::core::timeline_collect::collect_events; -use lore::core::timeline_expand::expand_timeline; -use lore::core::timeline_seed::seed_timeline; +use lore::timeline::collect::collect_events; +use lore::timeline::expand::expand_timeline; +use lore::timeline::seed::seed_timeline; +use lore::timeline::{TimelineEventType, resolve_entity_ref}; use rusqlite::Connection; use std::path::Path;