Compare commits
7 Commits
c54a969269
...
b168a58134
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b168a58134 | ||
|
|
b704e33188 | ||
|
|
6e82f723c3 | ||
|
|
940a96375a | ||
|
|
7dd86d5433 | ||
|
|
429c6f07d2 | ||
|
|
754efa4369 |
106
.claude/skills/release/SKILL.md
Normal file
106
.claude/skills/release/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: release
|
||||
description: Bump version, tag, and prepare for next development cycle
|
||||
version: 1.0.0
|
||||
author: Taylor Eernisse
|
||||
category: automation
|
||||
tags: ["release", "versioning", "semver", "git"]
|
||||
---
|
||||
# Release
|
||||
|
||||
Automate SemVer version bumps for the `lore` CLI.
|
||||
|
||||
## Invocation
|
||||
|
||||
```
|
||||
/release <type>
|
||||
```
|
||||
|
||||
Where `<type>` is one of:
|
||||
- **major** — breaking changes (0.5.0 -> 1.0.0)
|
||||
- **minor** — new features (0.5.0 -> 0.6.0)
|
||||
- **patch** / **hotfix** — bug fixes (0.5.0 -> 0.5.1)
|
||||
|
||||
If no type is provided, ask the user.
|
||||
|
||||
## Procedure
|
||||
|
||||
Follow these steps exactly. Do NOT skip any step.
|
||||
|
||||
### 1. Determine bump type
|
||||
|
||||
Parse the argument. Accept these aliases:
|
||||
- `major`, `breaking` -> MAJOR
|
||||
- `minor`, `feature`, `feat` -> MINOR
|
||||
- `patch`, `hotfix`, `fix` -> PATCH
|
||||
|
||||
If the argument doesn't match, ask the user to clarify.
|
||||
|
||||
### 2. Read current version
|
||||
|
||||
Read `Cargo.toml` and extract the `version = "X.Y.Z"` line. Parse into major, minor, patch integers.
|
||||
|
||||
### 3. Compute new version
|
||||
|
||||
- MAJOR: `(major+1).0.0`
|
||||
- MINOR: `major.(minor+1).0`
|
||||
- PATCH: `major.minor.(patch+1)`
|
||||
|
||||
### 4. Check preconditions
|
||||
|
||||
Run `git status` and `git log --oneline -5`. Show the user:
|
||||
- Current version: X.Y.Z
|
||||
- New version: A.B.C
|
||||
- Bump type: major/minor/patch
|
||||
- Working tree status (clean or dirty)
|
||||
- Last 5 commits (so they can confirm scope)
|
||||
|
||||
If the working tree is dirty, warn: "You have uncommitted changes. They will NOT be included in the release tag. Continue?"
|
||||
|
||||
Ask the user to confirm before proceeding.
|
||||
|
||||
### 5. Update Cargo.toml
|
||||
|
||||
Edit the `version = "..."` line in Cargo.toml to the new version.
|
||||
|
||||
### 6. Update Cargo.lock
|
||||
|
||||
Run `cargo check` to update Cargo.lock with the new version. This also verifies the project compiles.
|
||||
|
||||
### 7. Commit the version bump
|
||||
|
||||
```bash
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "release: v{NEW_VERSION}"
|
||||
```
|
||||
|
||||
### 8. Tag the release
|
||||
|
||||
```bash
|
||||
git tag v{NEW_VERSION}
|
||||
```
|
||||
|
||||
### 9. Report
|
||||
|
||||
Print a summary:
|
||||
```
|
||||
Release v{NEW_VERSION} created.
|
||||
|
||||
Previous: v{OLD_VERSION}
|
||||
Bump: {type}
|
||||
Tag: v{NEW_VERSION}
|
||||
Commit: {short hash}
|
||||
|
||||
To push: git push && git push --tags
|
||||
```
|
||||
|
||||
Do NOT push automatically. The user decides when to push.
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/release minor -> 0.5.0 -> 0.6.0
|
||||
/release hotfix -> 0.5.0 -> 0.5.1
|
||||
/release patch -> 0.5.0 -> 0.5.1
|
||||
/release major -> 0.5.0 -> 1.0.0
|
||||
```
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1106,7 +1106,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lore"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lore"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
description = "Gitlore - Local GitLab data management with semantic search"
|
||||
authors = ["Taylor Eernisse"]
|
||||
|
||||
@@ -11,3 +11,6 @@ CREATE INDEX IF NOT EXISTS idx_mrs_updated_at_desc
|
||||
-- MRs already have idx_discussions_mr_resolved (migration 006).
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_issue_resolved
|
||||
ON discussions(issue_id, resolvable, resolved);
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (19, strftime('%s', 'now') * 1000, 'List performance indexes');
|
||||
|
||||
@@ -131,8 +131,8 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--project",
|
||||
"--label",
|
||||
"--path",
|
||||
"--after",
|
||||
"--updated-after",
|
||||
"--since",
|
||||
"--updated-since",
|
||||
"--limit",
|
||||
"--explain",
|
||||
"--no-explain",
|
||||
@@ -294,16 +294,33 @@ fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> {
|
||||
|
||||
/// Run the pre-clap correction pass on raw args.
|
||||
///
|
||||
/// When `strict` is true (robot mode), only deterministic corrections are applied
|
||||
/// (single-dash long flags, case normalization). Fuzzy matching is disabled to
|
||||
/// prevent misleading agents with speculative corrections.
|
||||
///
|
||||
/// Returns the (possibly modified) args and any corrections applied.
|
||||
pub fn correct_args(raw: Vec<String>) -> CorrectionResult {
|
||||
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
|
||||
let subcommand = detect_subcommand(&raw);
|
||||
let valid = valid_flags_for(subcommand);
|
||||
|
||||
let mut corrected = Vec::with_capacity(raw.len());
|
||||
let mut corrections = Vec::new();
|
||||
let mut past_terminator = false;
|
||||
|
||||
for arg in raw {
|
||||
if let Some(fixed) = try_correct(&arg, &valid) {
|
||||
// B1: Stop correcting after POSIX `--` option terminator
|
||||
if arg == "--" {
|
||||
past_terminator = true;
|
||||
corrected.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if past_terminator {
|
||||
corrected.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(fixed) = try_correct(&arg, &valid, strict) {
|
||||
let s = fixed.corrected.clone();
|
||||
corrections.push(fixed);
|
||||
corrected.push(s);
|
||||
@@ -318,13 +335,33 @@ pub fn correct_args(raw: Vec<String>) -> CorrectionResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clap built-in flags that should never be corrected. These are handled by clap
|
||||
/// directly and are not in our GLOBAL_FLAGS registry.
|
||||
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
|
||||
|
||||
/// Try to correct a single arg. Returns `None` if no correction needed.
|
||||
fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
|
||||
///
|
||||
/// When `strict` is true, fuzzy matching is disabled — only deterministic
|
||||
/// corrections (single-dash fix, case normalization) are applied.
|
||||
fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correction> {
|
||||
// Only attempt correction on flag-like args (starts with `-`)
|
||||
if !arg.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
|
||||
// B2: Never correct clap built-in flags (--help, --version)
|
||||
let flag_part_for_builtin = if let Some(eq_pos) = arg.find('=') {
|
||||
&arg[..eq_pos]
|
||||
} else {
|
||||
arg
|
||||
};
|
||||
if CLAP_BUILTINS
|
||||
.iter()
|
||||
.any(|b| b.eq_ignore_ascii_case(flag_part_for_builtin))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip short flags — they're unambiguous single chars (-p, -n, -v, -J)
|
||||
// Also skip stacked short flags (-vvv)
|
||||
if !arg.starts_with("--") {
|
||||
@@ -371,8 +408,9 @@ fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
|
||||
});
|
||||
}
|
||||
|
||||
// Try fuzzy on the single-dash candidate
|
||||
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||
// Try fuzzy on the single-dash candidate (skip in strict mode)
|
||||
if !strict
|
||||
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||
&& score >= FUZZY_FLAG_THRESHOLD
|
||||
{
|
||||
return Some(Correction {
|
||||
@@ -415,8 +453,9 @@ fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 3: Fuzzy flag match — `--staate` -> `--state`
|
||||
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||
// Rule 3: Fuzzy flag match — `--staate` -> `--state` (skip in strict mode)
|
||||
if !strict
|
||||
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
|
||||
&& score >= FUZZY_FLAG_THRESHOLD
|
||||
{
|
||||
let corrected = match value_suffix {
|
||||
@@ -510,7 +549,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn single_dash_robot() {
|
||||
let result = correct_args(args("lore -robot issues -n 5"));
|
||||
let result = correct_args(args("lore -robot issues -n 5"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].original, "-robot");
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
@@ -523,7 +562,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn single_dash_state() {
|
||||
let result = correct_args(args("lore --robot issues -state opened"));
|
||||
let result = correct_args(args("lore --robot issues -state opened"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--state");
|
||||
}
|
||||
@@ -532,7 +571,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn case_robot() {
|
||||
let result = correct_args(args("lore --Robot issues"));
|
||||
let result = correct_args(args("lore --Robot issues"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
assert_eq!(
|
||||
@@ -543,7 +582,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn case_state_upper() {
|
||||
let result = correct_args(args("lore --robot issues --State opened"));
|
||||
let result = correct_args(args("lore --robot issues --State opened"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--state");
|
||||
assert_eq!(
|
||||
@@ -554,7 +593,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn case_all_upper() {
|
||||
let result = correct_args(args("lore --ROBOT issues --STATE opened"));
|
||||
let result = correct_args(args("lore --ROBOT issues --STATE opened"), false);
|
||||
assert_eq!(result.corrections.len(), 2);
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
assert_eq!(result.corrections[1].corrected, "--state");
|
||||
@@ -564,7 +603,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fuzzy_staate() {
|
||||
let result = correct_args(args("lore --robot issues --staate opened"));
|
||||
let result = correct_args(args("lore --robot issues --staate opened"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--state");
|
||||
assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||
@@ -572,7 +611,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fuzzy_projct() {
|
||||
let result = correct_args(args("lore --robot issues --projct group/repo"));
|
||||
let result = correct_args(args("lore --robot issues --projct group/repo"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--project");
|
||||
assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||
@@ -583,7 +622,7 @@ mod tests {
|
||||
#[test]
|
||||
fn already_correct() {
|
||||
let original = args("lore --robot issues --state opened -n 10");
|
||||
let result = correct_args(original.clone());
|
||||
let result = correct_args(original.clone(), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
assert_eq!(result.args, original);
|
||||
}
|
||||
@@ -591,27 +630,27 @@ mod tests {
|
||||
#[test]
|
||||
fn short_flags_untouched() {
|
||||
let original = args("lore -J issues -n 10 -s opened -p group/repo");
|
||||
let result = correct_args(original.clone());
|
||||
let result = correct_args(original.clone(), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_short_flags_untouched() {
|
||||
let original = args("lore -vvv issues");
|
||||
let result = correct_args(original.clone());
|
||||
let result = correct_args(original.clone(), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_args_untouched() {
|
||||
let result = correct_args(args("lore --robot search authentication"));
|
||||
let result = correct_args(args("lore --robot search authentication"), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildly_wrong_flag_not_corrected() {
|
||||
// `--xyzzy` shouldn't match anything above 0.8
|
||||
let result = correct_args(args("lore --robot issues --xyzzy foo"));
|
||||
let result = correct_args(args("lore --robot issues --xyzzy foo"), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
}
|
||||
|
||||
@@ -619,7 +658,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn flag_eq_value_case_correction() {
|
||||
let result = correct_args(args("lore --robot issues --State=opened"));
|
||||
let result = correct_args(args("lore --robot issues --State=opened"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--state=opened");
|
||||
}
|
||||
@@ -628,15 +667,81 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multiple_corrections() {
|
||||
let result = correct_args(args(
|
||||
"lore -robot issues --State opened --projct group/repo",
|
||||
));
|
||||
let result = correct_args(
|
||||
args("lore -robot issues --State opened --projct group/repo"),
|
||||
false,
|
||||
);
|
||||
assert_eq!(result.corrections.len(), 3);
|
||||
assert_eq!(result.args[1], "--robot");
|
||||
assert_eq!(result.args[3], "--state");
|
||||
assert_eq!(result.args[5], "--project");
|
||||
}
|
||||
|
||||
// ---- B1: POSIX -- option terminator ----
|
||||
|
||||
#[test]
|
||||
fn option_terminator_stops_corrections() {
|
||||
let result = correct_args(args("lore issues -- --staate --projct"), false);
|
||||
// Nothing after `--` should be corrected
|
||||
assert!(result.corrections.is_empty());
|
||||
assert_eq!(result.args[2], "--");
|
||||
assert_eq!(result.args[3], "--staate");
|
||||
assert_eq!(result.args[4], "--projct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_before_terminator_still_works() {
|
||||
let result = correct_args(args("lore --Robot issues -- --staate"), false);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
assert_eq!(result.args[4], "--staate"); // untouched after --
|
||||
}
|
||||
|
||||
// ---- B2: Clap built-in flags not corrected ----
|
||||
|
||||
#[test]
|
||||
fn version_flag_not_corrected() {
|
||||
let result = correct_args(args("lore --version"), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
assert_eq!(result.args[1], "--version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_flag_not_corrected() {
|
||||
let result = correct_args(args("lore --help"), false);
|
||||
assert!(result.corrections.is_empty());
|
||||
assert_eq!(result.args[1], "--help");
|
||||
}
|
||||
|
||||
// ---- I6: Strict mode (robot) disables fuzzy matching ----
|
||||
|
||||
#[test]
|
||||
fn strict_mode_disables_fuzzy() {
|
||||
// Fuzzy match works in non-strict
|
||||
let non_strict = correct_args(args("lore --robot issues --staate opened"), false);
|
||||
assert_eq!(non_strict.corrections.len(), 1);
|
||||
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
|
||||
|
||||
// Fuzzy match disabled in strict
|
||||
let strict = correct_args(args("lore --robot issues --staate opened"), true);
|
||||
assert!(strict.corrections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_still_fixes_case() {
|
||||
let result = correct_args(args("lore --Robot issues --State opened"), true);
|
||||
assert_eq!(result.corrections.len(), 2);
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
assert_eq!(result.corrections[1].corrected, "--state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_still_fixes_single_dash() {
|
||||
let result = correct_args(args("lore -robot issues"), true);
|
||||
assert_eq!(result.corrections.len(), 1);
|
||||
assert_eq!(result.corrections[0].corrected, "--robot");
|
||||
}
|
||||
|
||||
// ---- Teaching notes ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct IngestResult {
|
||||
pub notes_upserted: usize,
|
||||
pub resource_events_fetched: usize,
|
||||
pub resource_events_failed: usize,
|
||||
pub mr_diffs_fetched: usize,
|
||||
pub mr_diffs_failed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
@@ -606,6 +608,8 @@ async fn run_ingest_inner(
|
||||
total.mrs_skipped_discussion_sync += result.mrs_skipped_discussion_sync;
|
||||
total.resource_events_fetched += result.resource_events_fetched;
|
||||
total.resource_events_failed += result.resource_events_failed;
|
||||
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
||||
total.mr_diffs_failed += result.mr_diffs_failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ pub struct SearchCliFilters {
|
||||
pub project: Option<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub path: Option<String>,
|
||||
pub after: Option<String>,
|
||||
pub updated_after: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub updated_since: Option<String>,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
@@ -63,22 +63,36 @@ pub fn run_search(
|
||||
query: &str,
|
||||
cli_filters: SearchCliFilters,
|
||||
fts_mode: FtsQueryMode,
|
||||
requested_mode: &str,
|
||||
explain: bool,
|
||||
) -> Result<SearchResponse> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
// Determine actual mode: vector search requires embeddings, which need async + Ollama.
|
||||
// Until hybrid/semantic are wired up, we run lexical and warn if the user asked for more.
|
||||
let actual_mode = "lexical";
|
||||
if requested_mode != "lexical" {
|
||||
warnings.push(format!(
|
||||
"Requested mode '{}' is not yet available; falling back to lexical search.",
|
||||
requested_mode
|
||||
));
|
||||
}
|
||||
|
||||
let doc_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if doc_count == 0 {
|
||||
warnings.push("No documents indexed. Run 'lore generate-docs' first.".to_string());
|
||||
return Ok(SearchResponse {
|
||||
query: query.to_string(),
|
||||
mode: "lexical".to_string(),
|
||||
mode: actual_mode.to_string(),
|
||||
total_results: 0,
|
||||
results: vec![],
|
||||
warnings: vec!["No documents indexed. Run 'lore generate-docs' first.".to_string()],
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,25 +107,25 @@ pub fn run_search(
|
||||
.map(|p| resolve_project(&conn, p))
|
||||
.transpose()?;
|
||||
|
||||
let after = cli_filters
|
||||
.after
|
||||
let since = cli_filters
|
||||
.since
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
parse_since(s).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --after value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
s
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let updated_after = cli_filters
|
||||
.updated_after
|
||||
let updated_since = cli_filters
|
||||
.updated_since
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
parse_since(s).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --updated-after value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
"Invalid --updated-since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
s
|
||||
))
|
||||
})
|
||||
@@ -130,8 +144,8 @@ pub fn run_search(
|
||||
source_type,
|
||||
author: cli_filters.author,
|
||||
project_id,
|
||||
after,
|
||||
updated_after,
|
||||
since,
|
||||
updated_since,
|
||||
labels: cli_filters.labels,
|
||||
path,
|
||||
limit: cli_filters.limit,
|
||||
@@ -163,10 +177,10 @@ pub fn run_search(
|
||||
if filtered_ids.is_empty() {
|
||||
return Ok(SearchResponse {
|
||||
query: query.to_string(),
|
||||
mode: "lexical".to_string(),
|
||||
mode: actual_mode.to_string(),
|
||||
total_results: 0,
|
||||
results: vec![],
|
||||
warnings: vec![],
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,10 +224,10 @@ pub fn run_search(
|
||||
|
||||
Ok(SearchResponse {
|
||||
query: query.to_string(),
|
||||
mode: "lexical".to_string(),
|
||||
mode: actual_mode.to_string(),
|
||||
total_results: results.len(),
|
||||
results,
|
||||
warnings: vec![],
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct SyncResult {
|
||||
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_embedded: usize,
|
||||
}
|
||||
@@ -152,6 +154,8 @@ pub async fn run_sync(
|
||||
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;
|
||||
spinner.finish_and_clear();
|
||||
|
||||
if signal.is_cancelled() {
|
||||
@@ -264,6 +268,8 @@ pub async fn run_sync(
|
||||
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"
|
||||
@@ -287,6 +293,12 @@ pub fn print_sync(
|
||||
" Discussions fetched: {}",
|
||||
result.discussions_fetched
|
||||
);
|
||||
if result.mr_diffs_fetched > 0 || result.mr_diffs_failed > 0 {
|
||||
println!(" MR diffs fetched: {}", result.mr_diffs_fetched);
|
||||
if result.mr_diffs_failed > 0 {
|
||||
println!(" MR diffs failed: {}", result.mr_diffs_failed);
|
||||
}
|
||||
}
|
||||
if result.resource_events_fetched > 0 || result.resource_events_failed > 0 {
|
||||
println!(
|
||||
" Resource events fetched: {}",
|
||||
|
||||
@@ -10,6 +10,11 @@ use std::io::IsTerminal;
|
||||
#[command(name = "lore")]
|
||||
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
|
||||
#[command(subcommand_required = false)]
|
||||
#[command(after_long_help = "\x1b[1mEnvironment:\x1b[0m
|
||||
GITLAB_TOKEN GitLab personal access token (or name set in config)
|
||||
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
|
||||
LORE_CONFIG_PATH Override config file location
|
||||
NO_COLOR Disable color output (any non-empty value)")]
|
||||
pub struct Cli {
|
||||
/// Path to config file
|
||||
#[arg(short = 'c', long, global = true, help = "Path to config file")]
|
||||
@@ -541,13 +546,13 @@ pub struct SearchArgs {
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub path: Option<String>,
|
||||
|
||||
/// Filter by created after (7d, 2w, or YYYY-MM-DD)
|
||||
/// Filter by created since (7d, 2w, or YYYY-MM-DD)
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub after: Option<String>,
|
||||
pub since: Option<String>,
|
||||
|
||||
/// Filter by updated after (7d, 2w, or YYYY-MM-DD)
|
||||
#[arg(long = "updated-after", help_heading = "Filters")]
|
||||
pub updated_after: Option<String>,
|
||||
/// Filter by updated since (7d, 2w, or YYYY-MM-DD)
|
||||
#[arg(long = "updated-since", help_heading = "Filters")]
|
||||
pub updated_since: Option<String>,
|
||||
|
||||
/// Maximum results (default 20, max 100)
|
||||
#[arg(
|
||||
|
||||
@@ -14,6 +14,10 @@ pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
|
||||
}
|
||||
|
||||
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
|
||||
debug_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)
|
||||
|
||||
@@ -380,7 +380,8 @@ fn reset_discussion_watermarks(conn: &Connection, project_id: i64) -> Result<()>
|
||||
discussions_sync_attempts = 0,
|
||||
discussions_sync_last_error = NULL,
|
||||
resource_events_synced_for_updated_at = NULL,
|
||||
closes_issues_synced_for_updated_at = NULL
|
||||
closes_issues_synced_for_updated_at = NULL,
|
||||
diffs_synced_for_updated_at = NULL
|
||||
WHERE project_id = ?",
|
||||
[project_id],
|
||||
)?;
|
||||
|
||||
@@ -25,12 +25,14 @@ pub fn upsert_mr_file_changes(
|
||||
project_id: i64,
|
||||
diffs: &[GitLabMrDiff],
|
||||
) -> Result<usize> {
|
||||
conn.execute(
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
tx.execute(
|
||||
"DELETE FROM mr_file_changes WHERE merge_request_id = ?1",
|
||||
[mr_local_id],
|
||||
)?;
|
||||
|
||||
let mut stmt = conn.prepare_cached(
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
@@ -54,6 +56,10 @@ pub fn upsert_mr_file_changes(
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
// Drop the prepared statement before committing the transaction.
|
||||
drop(stmt);
|
||||
tx.commit()?;
|
||||
|
||||
if inserted > 0 {
|
||||
debug!(inserted, mr_local_id, "Stored MR file changes");
|
||||
}
|
||||
|
||||
@@ -516,7 +516,10 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
|
||||
tracing::Span::current().record("items_processed", result.mrs_upserted);
|
||||
tracing::Span::current().record("items_skipped", result.mrs_skipped_discussion_sync);
|
||||
tracing::Span::current().record("errors", result.resource_events_failed);
|
||||
tracing::Span::current().record(
|
||||
"errors",
|
||||
result.resource_events_failed + result.closes_issues_failed + result.mr_diffs_failed,
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -774,8 +777,9 @@ async fn drain_resource_events(
|
||||
for p in prefetched {
|
||||
match p.result {
|
||||
Ok((state_events, label_events, milestone_events)) => {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let store_result = store_resource_events(
|
||||
conn,
|
||||
&tx,
|
||||
p.project_id,
|
||||
&p.entity_type,
|
||||
p.entity_local_id,
|
||||
@@ -786,7 +790,6 @@ async fn drain_resource_events(
|
||||
|
||||
match store_result {
|
||||
Ok(()) => {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
complete_job_tx(&tx, p.job_id)?;
|
||||
update_resource_event_watermark_tx(
|
||||
&tx,
|
||||
@@ -797,6 +800,7 @@ async fn drain_resource_events(
|
||||
result.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
drop(tx);
|
||||
warn!(
|
||||
entity_type = %p.entity_type,
|
||||
entity_iid = p.entity_iid,
|
||||
@@ -861,6 +865,7 @@ async fn drain_resource_events(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Store resource events using the provided connection (caller manages the transaction).
|
||||
fn store_resource_events(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
@@ -870,11 +875,9 @@ fn store_resource_events(
|
||||
label_events: &[crate::gitlab::types::GitLabLabelEvent],
|
||||
milestone_events: &[crate::gitlab::types::GitLabMilestoneEvent],
|
||||
) -> Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
if !state_events.is_empty() {
|
||||
crate::core::events_db::upsert_state_events(
|
||||
&tx,
|
||||
conn,
|
||||
project_id,
|
||||
entity_type,
|
||||
entity_local_id,
|
||||
@@ -884,7 +887,7 @@ fn store_resource_events(
|
||||
|
||||
if !label_events.is_empty() {
|
||||
crate::core::events_db::upsert_label_events(
|
||||
&tx,
|
||||
conn,
|
||||
project_id,
|
||||
entity_type,
|
||||
entity_local_id,
|
||||
@@ -894,7 +897,7 @@ fn store_resource_events(
|
||||
|
||||
if !milestone_events.is_empty() {
|
||||
crate::core::events_db::upsert_milestone_events(
|
||||
&tx,
|
||||
conn,
|
||||
project_id,
|
||||
entity_type,
|
||||
entity_local_id,
|
||||
@@ -902,7 +905,6 @@ fn store_resource_events(
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1095,8 +1097,9 @@ async fn drain_mr_closes_issues(
|
||||
for p in prefetched {
|
||||
match p.result {
|
||||
Ok(closes_issues) => {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let store_result = store_closes_issues_refs(
|
||||
conn,
|
||||
&tx,
|
||||
project_id,
|
||||
p.entity_local_id,
|
||||
&closes_issues,
|
||||
@@ -1104,13 +1107,13 @@ async fn drain_mr_closes_issues(
|
||||
|
||||
match store_result {
|
||||
Ok(()) => {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
complete_job_tx(&tx, p.job_id)?;
|
||||
update_closes_issues_watermark_tx(&tx, p.entity_local_id)?;
|
||||
tx.commit()?;
|
||||
result.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
drop(tx);
|
||||
warn!(
|
||||
entity_iid = p.entity_iid,
|
||||
error = %e,
|
||||
@@ -1364,8 +1367,9 @@ async fn drain_mr_diffs(
|
||||
for p in prefetched {
|
||||
match p.result {
|
||||
Ok(diffs) => {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let store_result = super::mr_diffs::upsert_mr_file_changes(
|
||||
conn,
|
||||
&tx,
|
||||
p.entity_local_id,
|
||||
project_id,
|
||||
&diffs,
|
||||
@@ -1373,13 +1377,13 @@ async fn drain_mr_diffs(
|
||||
|
||||
match store_result {
|
||||
Ok(_) => {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
complete_job_tx(&tx, p.job_id)?;
|
||||
update_diffs_watermark_tx(&tx, p.entity_local_id)?;
|
||||
tx.commit()?;
|
||||
result.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
drop(tx);
|
||||
warn!(
|
||||
entity_iid = p.entity_iid,
|
||||
error = %e,
|
||||
|
||||
57
src/main.rs
57
src/main.rs
@@ -52,7 +52,7 @@ async fn main() {
|
||||
|
||||
// Phase 1.5: Pre-clap arg correction for agent typo tolerance
|
||||
let raw_args: Vec<String> = std::env::args().collect();
|
||||
let correction_result = autocorrect::correct_args(raw_args);
|
||||
let correction_result = autocorrect::correct_args(raw_args, robot_mode_early);
|
||||
|
||||
// Emit correction warnings to stderr (before clap parsing, so they appear
|
||||
// even if clap still fails on something else)
|
||||
@@ -142,6 +142,10 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// I1: Respect NO_COLOR convention (https://no-color.org/)
|
||||
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
|
||||
console::set_colors_enabled(false);
|
||||
} else {
|
||||
match cli.color.as_str() {
|
||||
"never" => console::set_colors_enabled(false),
|
||||
"always" => console::set_colors_enabled(true),
|
||||
@@ -150,6 +154,7 @@ async fn main() {
|
||||
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quiet = cli.quiet;
|
||||
|
||||
@@ -451,11 +456,13 @@ fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionR
|
||||
|
||||
if robot_mode {
|
||||
let error_code = map_clap_error_kind(e.kind());
|
||||
let message = e
|
||||
.to_string()
|
||||
let full_msg = e.to_string();
|
||||
let message = full_msg
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("Parse error")
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let (suggestion, correction, valid_values) = match e.kind() {
|
||||
@@ -684,10 +691,11 @@ fn handle_issues(
|
||||
print_show_issue(&result);
|
||||
}
|
||||
} else {
|
||||
let state_normalized = args.state.as_deref().map(str::to_lowercase);
|
||||
let filters = ListFilters {
|
||||
limit: args.limit,
|
||||
project: args.project.as_deref(),
|
||||
state: args.state.as_deref(),
|
||||
state: state_normalized.as_deref(),
|
||||
author: args.author.as_deref(),
|
||||
assignee: args.assignee.as_deref(),
|
||||
labels: args.label.as_deref(),
|
||||
@@ -736,10 +744,11 @@ fn handle_mrs(
|
||||
print_show_mr(&result);
|
||||
}
|
||||
} else {
|
||||
let state_normalized = args.state.as_deref().map(str::to_lowercase);
|
||||
let filters = MrListFilters {
|
||||
limit: args.limit,
|
||||
project: args.project.as_deref(),
|
||||
state: args.state.as_deref(),
|
||||
state: state_normalized.as_deref(),
|
||||
author: args.author.as_deref(),
|
||||
assignee: args.assignee.as_deref(),
|
||||
reviewer: args.reviewer.as_deref(),
|
||||
@@ -1714,13 +1723,20 @@ async fn handle_search(
|
||||
project: args.project,
|
||||
labels: args.label,
|
||||
path: args.path,
|
||||
after: args.after,
|
||||
updated_after: args.updated_after,
|
||||
since: args.since,
|
||||
updated_since: args.updated_since,
|
||||
limit: args.limit,
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let response = run_search(&config, &args.query, cli_filters, fts_mode, explain)?;
|
||||
let response = run_search(
|
||||
&config,
|
||||
&args.query,
|
||||
cli_filters,
|
||||
fts_mode,
|
||||
&args.mode,
|
||||
explain,
|
||||
)?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
if robot_mode {
|
||||
@@ -1895,6 +1911,8 @@ struct HealthData {
|
||||
db_found: bool,
|
||||
schema_current: bool,
|
||||
schema_version: i32,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
actions: Vec<String>,
|
||||
}
|
||||
|
||||
async fn handle_health(
|
||||
@@ -1929,6 +1947,17 @@ async fn handle_health(
|
||||
|
||||
let healthy = config_found && db_found && schema_current;
|
||||
|
||||
let mut actions = Vec::new();
|
||||
if !config_found {
|
||||
actions.push("lore init".to_string());
|
||||
}
|
||||
if !db_found && config_found {
|
||||
actions.push("lore sync".to_string());
|
||||
}
|
||||
if db_found && !schema_current {
|
||||
actions.push("lore migrate".to_string());
|
||||
}
|
||||
|
||||
if robot_mode {
|
||||
let output = HealthOutput {
|
||||
ok: true,
|
||||
@@ -1938,6 +1967,7 @@ async fn handle_health(
|
||||
db_found,
|
||||
schema_current,
|
||||
schema_version,
|
||||
actions,
|
||||
},
|
||||
meta: RobotMeta {
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
@@ -2111,7 +2141,7 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
|
||||
},
|
||||
"search": {
|
||||
"description": "Search indexed documents (lexical, hybrid, semantic)",
|
||||
"flags": ["<QUERY>", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--after", "--updated-after", "-n/--limit", "--explain", "--no-explain", "--fts-mode"],
|
||||
"flags": ["<QUERY>", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--since", "--updated-since", "-n/--limit", "--explain", "--no-explain", "--fts-mode"],
|
||||
"example": "lore --robot search 'authentication bug' --mode hybrid --limit 10",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
@@ -2385,12 +2415,13 @@ async fn handle_list_compat(
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
|
||||
let state_normalized = state_filter.map(str::to_lowercase);
|
||||
match entity {
|
||||
"issues" => {
|
||||
let filters = ListFilters {
|
||||
limit,
|
||||
project: project_filter,
|
||||
state: state_filter,
|
||||
state: state_normalized.as_deref(),
|
||||
author: author_filter,
|
||||
assignee: assignee_filter,
|
||||
labels: label_filter,
|
||||
@@ -2418,7 +2449,7 @@ async fn handle_list_compat(
|
||||
let filters = MrListFilters {
|
||||
limit,
|
||||
project: project_filter,
|
||||
state: state_filter,
|
||||
state: state_normalized.as_deref(),
|
||||
author: author_filter,
|
||||
assignee: assignee_filter,
|
||||
reviewer: reviewer_filter,
|
||||
|
||||
@@ -16,8 +16,8 @@ pub struct SearchFilters {
|
||||
pub source_type: Option<SourceType>,
|
||||
pub author: Option<String>,
|
||||
pub project_id: Option<i64>,
|
||||
pub after: Option<i64>,
|
||||
pub updated_after: Option<i64>,
|
||||
pub since: Option<i64>,
|
||||
pub updated_since: Option<i64>,
|
||||
pub labels: Vec<String>,
|
||||
pub path: Option<PathFilter>,
|
||||
pub limit: usize,
|
||||
@@ -28,8 +28,8 @@ impl SearchFilters {
|
||||
self.source_type.is_some()
|
||||
|| self.author.is_some()
|
||||
|| self.project_id.is_some()
|
||||
|| self.after.is_some()
|
||||
|| self.updated_after.is_some()
|
||||
|| self.since.is_some()
|
||||
|| self.updated_since.is_some()
|
||||
|| !self.labels.is_empty()
|
||||
|| self.path.is_some()
|
||||
}
|
||||
@@ -85,15 +85,15 @@ pub fn apply_filters(
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(after) = filters.after {
|
||||
if let Some(since) = filters.since {
|
||||
sql.push_str(&format!(" AND d.created_at >= ?{}", param_idx));
|
||||
params.push(Box::new(after));
|
||||
params.push(Box::new(since));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(updated_after) = filters.updated_after {
|
||||
if let Some(updated_since) = filters.updated_since {
|
||||
sql.push_str(&format!(" AND d.updated_at >= ?{}", param_idx));
|
||||
params.push(Box::new(updated_after));
|
||||
params.push(Box::new(updated_since));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ pub fn search_vector(
|
||||
.collect();
|
||||
|
||||
let max_chunks = max_chunks_per_document(conn).max(1);
|
||||
let multiplier = ((max_chunks.unsigned_abs() as usize * 3 / 2) + 1).max(8);
|
||||
let k = limit * multiplier;
|
||||
let multiplier = ((max_chunks.unsigned_abs() as usize * 3 / 2) + 1).clamp(8, 200);
|
||||
let k = (limit * multiplier).min(10_000);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rowid, distance
|
||||
|
||||
Reference in New Issue
Block a user