Add a "Phase 1.5" status enrichment step to the issue ingestion pipeline that fetches work item statuses via the GitLab GraphQL API after the standard REST API ingestion completes. Schema changes (migration 021): - Add status_name, status_category, status_color, status_icon_name, and status_synced_at columns to the issues table (all nullable) Ingestion pipeline changes: - New `enrich_issue_statuses_txn()` function that applies fetched statuses in a single transaction with two phases: clear stale statuses for issues that no longer have a status widget, then apply new/updated statuses from the GraphQL response - ProgressEvent variants for status enrichment (complete/skipped) - IngestProjectResult tracks enrichment metrics (seen, enriched, cleared, without_widget, partial_error_count, enrichment_mode, errors) - Robot mode JSON output includes per-project status enrichment details Configuration: - New `sync.fetchWorkItemStatus` config option (defaults true) to disable GraphQL status enrichment on instances without Premium/Ultimate - `LoreError::GitLabAuthFailed` now treated as permanent API error so status enrichment auth failures don't trigger retries Also removes the unnecessary nested SAVEPOINT in store_closes_issues_refs (already runs within the orchestrator's transaction context). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
11 KiB
Rust
296 lines
11 KiB
Rust
use serde::Serialize;
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ErrorCode {
|
|
ConfigNotFound,
|
|
ConfigInvalid,
|
|
GitLabAuthFailed,
|
|
GitLabNotFound,
|
|
GitLabRateLimited,
|
|
GitLabNetworkError,
|
|
DatabaseLocked,
|
|
DatabaseError,
|
|
MigrationFailed,
|
|
TokenNotSet,
|
|
TransformError,
|
|
IoError,
|
|
InternalError,
|
|
OllamaUnavailable,
|
|
OllamaModelNotFound,
|
|
EmbeddingFailed,
|
|
NotFound,
|
|
Ambiguous,
|
|
}
|
|
|
|
impl std::fmt::Display for ErrorCode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let code = match self {
|
|
Self::ConfigNotFound => "CONFIG_NOT_FOUND",
|
|
Self::ConfigInvalid => "CONFIG_INVALID",
|
|
Self::GitLabAuthFailed => "GITLAB_AUTH_FAILED",
|
|
Self::GitLabNotFound => "GITLAB_NOT_FOUND",
|
|
Self::GitLabRateLimited => "GITLAB_RATE_LIMITED",
|
|
Self::GitLabNetworkError => "GITLAB_NETWORK_ERROR",
|
|
Self::DatabaseLocked => "DB_LOCKED",
|
|
Self::DatabaseError => "DB_ERROR",
|
|
Self::MigrationFailed => "MIGRATION_FAILED",
|
|
Self::TokenNotSet => "TOKEN_NOT_SET",
|
|
Self::TransformError => "TRANSFORM_ERROR",
|
|
Self::IoError => "IO_ERROR",
|
|
Self::InternalError => "INTERNAL_ERROR",
|
|
Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE",
|
|
Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND",
|
|
Self::EmbeddingFailed => "EMBEDDING_FAILED",
|
|
Self::NotFound => "NOT_FOUND",
|
|
Self::Ambiguous => "AMBIGUOUS",
|
|
};
|
|
write!(f, "{code}")
|
|
}
|
|
}
|
|
|
|
impl ErrorCode {
|
|
pub fn exit_code(&self) -> i32 {
|
|
match self {
|
|
Self::InternalError => 1,
|
|
Self::ConfigNotFound => 20,
|
|
Self::ConfigInvalid => 3,
|
|
Self::TokenNotSet => 4,
|
|
Self::GitLabAuthFailed => 5,
|
|
Self::GitLabNotFound => 6,
|
|
Self::GitLabRateLimited => 7,
|
|
Self::GitLabNetworkError => 8,
|
|
Self::DatabaseLocked => 9,
|
|
Self::DatabaseError => 10,
|
|
Self::MigrationFailed => 11,
|
|
Self::IoError => 12,
|
|
Self::TransformError => 13,
|
|
Self::OllamaUnavailable => 14,
|
|
Self::OllamaModelNotFound => 15,
|
|
Self::EmbeddingFailed => 16,
|
|
Self::NotFound => 17,
|
|
Self::Ambiguous => 18,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum LoreError {
|
|
#[error("Config file not found at {path}. Run \"lore init\" first.")]
|
|
ConfigNotFound { path: String },
|
|
|
|
#[error("Invalid config: {details}")]
|
|
ConfigInvalid { details: String },
|
|
|
|
#[error("GitLab authentication failed. Check your token has read_api scope.")]
|
|
GitLabAuthFailed,
|
|
|
|
#[error("GitLab resource not found: {resource}")]
|
|
GitLabNotFound { resource: String },
|
|
|
|
#[error("Rate limited. Retry after {retry_after}s")]
|
|
GitLabRateLimited { retry_after: u64 },
|
|
|
|
#[error("Cannot connect to GitLab at {base_url}")]
|
|
GitLabNetworkError {
|
|
base_url: String,
|
|
#[source]
|
|
source: Option<reqwest::Error>,
|
|
},
|
|
|
|
#[error(
|
|
"Another sync is running (owner: {owner}, started: {started_at}). Use --force to override if stale."
|
|
)]
|
|
DatabaseLocked { owner: String, started_at: String },
|
|
|
|
#[error("Migration {version} failed: {message}")]
|
|
MigrationFailed {
|
|
version: i32,
|
|
message: String,
|
|
#[source]
|
|
source: Option<rusqlite::Error>,
|
|
},
|
|
|
|
#[error("GitLab token not set. Export {env_var} environment variable.")]
|
|
TokenNotSet { env_var: String },
|
|
|
|
#[error("Database error: {0}")]
|
|
Database(#[from] rusqlite::Error),
|
|
|
|
#[error("HTTP error: {0}")]
|
|
Http(#[from] reqwest::Error),
|
|
|
|
#[error("JSON error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("Transform error: {0}")]
|
|
Transform(#[from] crate::gitlab::transformers::issue::TransformError),
|
|
|
|
#[error("Not found: {0}")]
|
|
NotFound(String),
|
|
|
|
#[error("Ambiguous: {0}")]
|
|
Ambiguous(String),
|
|
|
|
#[error("{0}")]
|
|
Other(String),
|
|
|
|
#[error("Cannot connect to Ollama at {base_url}. Is it running?")]
|
|
OllamaUnavailable {
|
|
base_url: String,
|
|
#[source]
|
|
source: Option<reqwest::Error>,
|
|
},
|
|
|
|
#[error("Ollama model '{model}' not found. Run: ollama pull {model}")]
|
|
OllamaModelNotFound { model: String },
|
|
|
|
#[error("Embedding failed for document {document_id}: {reason}")]
|
|
EmbeddingFailed { document_id: i64, reason: String },
|
|
|
|
#[error("No embeddings found. Run: lore embed")]
|
|
EmbeddingsNotBuilt,
|
|
}
|
|
|
|
impl LoreError {
|
|
pub fn code(&self) -> ErrorCode {
|
|
match self {
|
|
Self::ConfigNotFound { .. } => ErrorCode::ConfigNotFound,
|
|
Self::ConfigInvalid { .. } => ErrorCode::ConfigInvalid,
|
|
Self::GitLabAuthFailed => ErrorCode::GitLabAuthFailed,
|
|
Self::GitLabNotFound { .. } => ErrorCode::GitLabNotFound,
|
|
Self::GitLabRateLimited { .. } => ErrorCode::GitLabRateLimited,
|
|
Self::GitLabNetworkError { .. } => ErrorCode::GitLabNetworkError,
|
|
Self::DatabaseLocked { .. } => ErrorCode::DatabaseLocked,
|
|
Self::MigrationFailed { .. } => ErrorCode::MigrationFailed,
|
|
Self::TokenNotSet { .. } => ErrorCode::TokenNotSet,
|
|
Self::Database(_) => ErrorCode::DatabaseError,
|
|
Self::Http(_) => ErrorCode::GitLabNetworkError,
|
|
Self::Json(_) => ErrorCode::InternalError,
|
|
Self::Io(_) => ErrorCode::IoError,
|
|
Self::Transform(_) => ErrorCode::TransformError,
|
|
Self::NotFound(_) => ErrorCode::NotFound,
|
|
Self::Ambiguous(_) => ErrorCode::Ambiguous,
|
|
Self::Other(_) => ErrorCode::InternalError,
|
|
Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable,
|
|
Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound,
|
|
Self::EmbeddingFailed { .. } => ErrorCode::EmbeddingFailed,
|
|
Self::EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed,
|
|
}
|
|
}
|
|
|
|
pub fn suggestion(&self) -> Option<&'static str> {
|
|
match self {
|
|
Self::ConfigNotFound { .. } => Some(
|
|
"Run 'lore init' to set up your GitLab connection.\n\n Expected: ~/.config/lore/config.json",
|
|
),
|
|
Self::ConfigInvalid { .. } => Some(
|
|
"Check config file syntax or run 'lore init' to recreate.\n\n Example:\n lore init\n lore init --force",
|
|
),
|
|
Self::GitLabAuthFailed => Some(
|
|
"Verify token has read_api scope and is not expired.\n\n Example:\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n lore auth",
|
|
),
|
|
Self::GitLabNotFound { .. } => Some(
|
|
"Check the resource path exists and you have access.\n\n Example:\n lore issues -p group/project\n lore mrs -p group/project",
|
|
),
|
|
Self::GitLabRateLimited { .. } => Some("Wait and retry, or reduce request frequency"),
|
|
Self::GitLabNetworkError { .. } => Some(
|
|
"Check network connection and GitLab URL.\n\n Example:\n lore doctor\n lore auth",
|
|
),
|
|
Self::DatabaseLocked { .. } => Some(
|
|
"Wait for other sync to complete or use --force.\n\n Example:\n lore ingest --force\n lore ingest issues --force",
|
|
),
|
|
Self::MigrationFailed { .. } => Some(
|
|
"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.",
|
|
),
|
|
Self::Database(_) => Some(
|
|
"Check database file permissions or reset with 'lore reset'.\n\n Example:\n lore doctor\n lore reset --yes",
|
|
),
|
|
Self::Http(_) => Some("Check network connection"),
|
|
Self::NotFound(_) => {
|
|
Some("Verify the entity exists.\n\n Example:\n lore issues\n lore mrs")
|
|
}
|
|
Self::Ambiguous(_) => Some(
|
|
"Use -p to choose a specific project.\n\n Example:\n lore issues 42 -p group/project-a\n lore mrs 99 -p group/project-b",
|
|
),
|
|
Self::OllamaUnavailable { .. } => Some("Start Ollama: ollama serve"),
|
|
Self::OllamaModelNotFound { .. } => {
|
|
Some("Pull the model: ollama pull nomic-embed-text")
|
|
}
|
|
Self::EmbeddingFailed { .. } => {
|
|
Some("Check Ollama logs or retry with 'lore embed --retry-failed'")
|
|
}
|
|
Self::EmbeddingsNotBuilt => Some("Generate embeddings first: lore embed"),
|
|
Self::Json(_) | Self::Io(_) | Self::Transform(_) | Self::Other(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn is_permanent_api_error(&self) -> bool {
|
|
matches!(self, Self::GitLabNotFound { .. } | Self::GitLabAuthFailed)
|
|
}
|
|
|
|
pub fn exit_code(&self) -> i32 {
|
|
self.code().exit_code()
|
|
}
|
|
|
|
pub fn actions(&self) -> Vec<&'static str> {
|
|
match self {
|
|
Self::ConfigNotFound { .. } => vec!["lore init"],
|
|
Self::ConfigInvalid { .. } => vec!["lore init --force"],
|
|
Self::GitLabAuthFailed => {
|
|
vec!["export GITLAB_TOKEN=glpat-xxx", "lore auth"]
|
|
}
|
|
Self::TokenNotSet { .. } => vec!["export GITLAB_TOKEN=glpat-xxx"],
|
|
Self::OllamaUnavailable { .. } => vec!["ollama serve"],
|
|
Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"],
|
|
Self::DatabaseLocked { .. } => vec!["lore ingest --force"],
|
|
Self::EmbeddingsNotBuilt => vec!["lore embed"],
|
|
Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"],
|
|
Self::MigrationFailed { .. } => vec!["lore migrate"],
|
|
Self::GitLabNetworkError { .. } => vec!["lore doctor"],
|
|
_ => vec![],
|
|
}
|
|
}
|
|
|
|
pub fn to_robot_error(&self) -> RobotError {
|
|
let actions = self.actions().into_iter().map(String::from).collect();
|
|
RobotError {
|
|
code: self.code().to_string(),
|
|
message: self.to_string(),
|
|
suggestion: self.suggestion().map(String::from),
|
|
actions,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct RobotError {
|
|
pub code: String,
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub suggestion: Option<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub actions: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct RobotErrorOutput {
|
|
pub error: RobotError,
|
|
}
|
|
|
|
impl From<&LoreError> for RobotErrorOutput {
|
|
fn from(e: &LoreError) -> Self {
|
|
Self {
|
|
error: e.to_robot_error(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, LoreError>;
|