feat(surgical-sync): add per-IID surgical sync pipeline with preflight validation
Add the ability to sync specific issues or merge requests by IID without
running a full incremental sync. This enables fast, targeted data refresh
for individual entities — useful for agent workflows, debugging, and
real-time investigation of specific issues or MRs.
Architecture:
- New CLI flags: --issue <IID> and --mr <IID> (repeatable, up to 100 total)
scoped to a single project via -p/--project
- Preflight phase validates all IIDs exist on GitLab before any DB writes,
with TOCTOU-aware soft verification at ingest time
- 6-stage pipeline: preflight -> fetch -> ingest -> dependents -> docs -> embed
- Each stage is cancellation-aware via ShutdownSignal
- Dedicated SyncRunRecorder extensions track surgical-specific counters
(issues_fetched, mrs_ingested, docs_regenerated, etc.)
New modules:
- src/ingestion/surgical.rs: Core surgical fetch/ingest/dependent logic
with preflight_fetch(), ingest_issue_by_iid(), ingest_mr_by_iid(),
and fetch_dependents_for_{issue,mr}()
- src/cli/commands/sync_surgical.rs: Full CLI orchestrator with progress
spinners, human/robot output, and cancellation handling
- src/embedding/pipeline.rs: embed_documents_by_ids() for scoped embedding
- src/documents/regenerator.rs: regenerate_dirty_documents_for_sources()
for scoped document regeneration
Database changes:
- Migration 027: Extends sync_runs with mode, phase, surgical_iids_json,
per-entity counters, and cancelled_at column
- New indexes: idx_sync_runs_mode_started, idx_sync_runs_status_phase_started
GitLab client:
- get_issue_by_iid() and get_mr_by_iid() single-entity fetch methods
Error handling:
- New SurgicalPreflightFailed error variant with entity_type, iid, project,
and reason fields. Shares exit code 6 with GitLabNotFound.
Includes comprehensive test coverage:
- 645 lines of surgical ingestion tests (wiremock-based)
- 184 lines of scoped embedding tests
- 85 lines of scoped regeneration tests
- 113 lines of GitLab client single-entity tests
- 236 lines of sync_run surgical column/counter tests
- Unit tests for SyncOptions, error codes, and CLI validation
This commit is contained in:
@@ -21,6 +21,7 @@ pub enum ErrorCode {
|
||||
EmbeddingFailed,
|
||||
NotFound,
|
||||
Ambiguous,
|
||||
SurgicalPreflightFailed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ErrorCode {
|
||||
@@ -44,6 +45,7 @@ impl std::fmt::Display for ErrorCode {
|
||||
Self::EmbeddingFailed => "EMBEDDING_FAILED",
|
||||
Self::NotFound => "NOT_FOUND",
|
||||
Self::Ambiguous => "AMBIGUOUS",
|
||||
Self::SurgicalPreflightFailed => "SURGICAL_PREFLIGHT_FAILED",
|
||||
};
|
||||
write!(f, "{code}")
|
||||
}
|
||||
@@ -70,6 +72,9 @@ impl ErrorCode {
|
||||
Self::EmbeddingFailed => 16,
|
||||
Self::NotFound => 17,
|
||||
Self::Ambiguous => 18,
|
||||
// Shares exit code 6 with GitLabNotFound — same semantic category (resource not found).
|
||||
// Robot consumers distinguish via ErrorCode string, not exit code.
|
||||
Self::SurgicalPreflightFailed => 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +116,7 @@ pub enum LoreError {
|
||||
source: Option<rusqlite::Error>,
|
||||
},
|
||||
|
||||
#[error("GitLab token not set. Export {env_var} environment variable.")]
|
||||
#[error("GitLab token not set. Run 'lore token set' or export {env_var}.")]
|
||||
TokenNotSet { env_var: String },
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
@@ -153,6 +158,14 @@ pub enum LoreError {
|
||||
|
||||
#[error("No embeddings found. Run: lore embed")]
|
||||
EmbeddingsNotBuilt,
|
||||
|
||||
#[error("Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}")]
|
||||
SurgicalPreflightFailed {
|
||||
entity_type: String,
|
||||
iid: u64,
|
||||
project: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl LoreError {
|
||||
@@ -179,6 +192,7 @@ impl LoreError {
|
||||
Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound,
|
||||
Self::EmbeddingFailed { .. } => ErrorCode::EmbeddingFailed,
|
||||
Self::EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed,
|
||||
Self::SurgicalPreflightFailed { .. } => ErrorCode::SurgicalPreflightFailed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +221,7 @@ impl LoreError {
|
||||
"Check database file permissions or reset with 'lore reset'.\n\n Example:\n lore migrate\n lore reset --yes",
|
||||
),
|
||||
Self::TokenNotSet { .. } => Some(
|
||||
"Export the token to your shell:\n\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n Your token needs the read_api scope.",
|
||||
"Set your token:\n\n lore token set\n\n Or export to your shell:\n\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n Your token needs the read_api scope.",
|
||||
),
|
||||
Self::Database(_) => Some(
|
||||
"Check database file permissions or reset with 'lore reset'.\n\n Example:\n lore doctor\n lore reset --yes",
|
||||
@@ -227,6 +241,9 @@ impl LoreError {
|
||||
Some("Check Ollama logs or retry with 'lore embed --retry-failed'")
|
||||
}
|
||||
Self::EmbeddingsNotBuilt => Some("Generate embeddings first: lore embed"),
|
||||
Self::SurgicalPreflightFailed { .. } => Some(
|
||||
"Verify the IID exists in the project and you have access.\n\n Example:\n lore issues -p <project>\n lore mrs -p <project>",
|
||||
),
|
||||
Self::Json(_) | Self::Io(_) | Self::Transform(_) | Self::Other(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -246,7 +263,7 @@ impl LoreError {
|
||||
Self::GitLabAuthFailed => {
|
||||
vec!["export GITLAB_TOKEN=glpat-xxx", "lore auth"]
|
||||
}
|
||||
Self::TokenNotSet { .. } => vec!["export GITLAB_TOKEN=glpat-xxx"],
|
||||
Self::TokenNotSet { .. } => vec!["lore token set", "export GITLAB_TOKEN=glpat-xxx"],
|
||||
Self::OllamaUnavailable { .. } => vec!["ollama serve"],
|
||||
Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"],
|
||||
Self::DatabaseLocked { .. } => vec!["lore ingest --force"],
|
||||
@@ -254,6 +271,9 @@ impl LoreError {
|
||||
Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"],
|
||||
Self::MigrationFailed { .. } => vec!["lore migrate"],
|
||||
Self::GitLabNetworkError { .. } => vec!["lore doctor"],
|
||||
Self::SurgicalPreflightFailed { .. } => {
|
||||
vec!["lore issues -p <project>", "lore mrs -p <project>"]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -293,3 +313,40 @@ impl From<&LoreError> for RobotErrorOutput {
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, LoreError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn surgical_preflight_failed_display() {
|
||||
let err = LoreError::SurgicalPreflightFailed {
|
||||
entity_type: "issue".to_string(),
|
||||
iid: 42,
|
||||
project: "group/repo".to_string(),
|
||||
reason: "not found on GitLab".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("issue"), "missing entity_type: {msg}");
|
||||
assert!(msg.contains("42"), "missing iid: {msg}");
|
||||
assert!(msg.contains("group/repo"), "missing project: {msg}");
|
||||
assert!(msg.contains("not found on GitLab"), "missing reason: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surgical_preflight_failed_error_code() {
|
||||
let code = ErrorCode::SurgicalPreflightFailed;
|
||||
assert_eq!(code.exit_code(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surgical_preflight_failed_code_mapping() {
|
||||
let err = LoreError::SurgicalPreflightFailed {
|
||||
entity_type: "merge_request".to_string(),
|
||||
iid: 99,
|
||||
project: "ns/proj".to_string(),
|
||||
reason: "404".to_string(),
|
||||
};
|
||||
assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user