--- plan: true title: "Work Item Status via GraphQL Enrichment" status: iterating iteration: 7 target_iterations: 8 beads_revision: 1 related_plans: [] created: 2026-02-10 updated: 2026-02-11 --- # Work Item Status via GraphQL Enrichment > **Bead:** bd-2y79 | **Priority:** P1 | **Status:** Planning > **Created:** 2026-02-10 ## Problem GitLab issues have native work item status (To do, In progress, Done, Won't do, Duplicate) but this is only available via GraphQL — not the REST API we use for ingestion. Without this data, `lore` cannot report or filter by workflow status, making it invisible to agents and humans. --- ## Acceptance Criteria Each criterion is independently testable. Implementation is complete when ALL pass. ### AC-1: GraphQL Client (Unit) - [ ] `GraphqlClient::query()` POSTs to `{base_url}/api/graphql` with `Content-Type: application/json` - [ ] Request uses `Authorization: Bearer {token}` header (NOT `PRIVATE-TOKEN`) - [ ] Request body is `{"query": "...", "variables": {...}}` - [ ] Successful response: parses `data` field from JSON envelope - [ ] Error response: if top-level `errors` array is non-empty AND `data` field is absent/null, returns `LoreError` with first error message - [ ] Partial-data response: if `errors` array is non-empty BUT `data` field is present and non-null, returns `GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some("...") }` (does NOT fail the query — GraphQL spec permits `data` + `errors` coexistence for partial results) - [ ] `GraphqlQueryResult` struct: `data: serde_json::Value`, `had_partial_errors: bool`, `first_partial_error: Option` — enables callers to propagate partial-error metadata to observability surfaces - [ ] Successful response (no errors): returns `GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None }` - [ ] HTTP 401 → `LoreError::GitLabAuthFailed` - [ ] HTTP 403 → `LoreError::GitLabAuthFailed` (forbidden treated as auth failure — no separate variant needed) - [ ] HTTP 404 → `LoreError::GitLabNotFound` - [ ] HTTP 429 → `LoreError::GitLabRateLimited` (respects `Retry-After` header — supports both delta-seconds and HTTP-date formats, falls back to 60s if unparseable) - [ ] Network error → `LoreError::Other` ### AC-2: Status Types (Unit) - [ ] `WorkItemStatus` struct has `name: String`, `category: Option`, `color: Option`, `icon_name: Option` - [ ] `category` stored as raw string from GraphQL (e.g., `"IN_PROGRESS"`, `"TO_DO"`) — no enum, no normalization - [ ] `WorkItemStatus` deserializes from GraphQL JSON shape with `name`, `category`, `color`, `iconName` - [ ] All fields except `name` are `Option` — absent fields deserialize to `None` - [ ] Custom statuses (18.5+) with non-standard category values deserialize without error ### AC-3: Status Fetcher (Integration) - [ ] `fetch_issue_statuses()` returns `FetchStatusResult` containing: - `statuses: HashMap` keyed by issue IID (parsed from GraphQL's String to i64) - `all_fetched_iids: HashSet` — all IIDs seen in GraphQL response (for staleness clearing) - `unsupported_reason: Option` — set when enrichment was skipped due to 404/403 (enables orchestrator to distinguish "no statuses" from "feature unavailable") - `partial_error_count: usize` — count of pages that returned partial-data responses (data + errors) - `first_partial_error: Option` — first partial-error message for diagnostic surfacing - [ ] Paginates: follows `pageInfo.endCursor` + `hasNextPage` until all pages consumed - [ ] Pagination guard: if `hasNextPage=true` but `endCursor` is `None` or unchanged, aborts pagination loop with a warning log and returns the partial result collected so far (prevents infinite loops from GraphQL cursor bugs) - [ ] Adaptive page size: starts with `first=100`; on GraphQL complexity or timeout errors (detected via error message substring matching for `"complexity"` or `"timeout"`), retries the same cursor with halved page size (100→50→25→10); if page size 10 still fails, returns the error - [ ] Query uses `$first: Int!` variable (not hardcoded `first: 100`) to support adaptive page sizing - [ ] Query includes `__typename` in `widgets` selection; parser matches `__typename == "WorkItemWidgetStatus"` for deterministic widget identification (no heuristic try-deserialize) - [ ] Non-status widgets are ignored deterministically via `__typename` check - [ ] Issues with no status widget in `widgets` array → in `all_fetched_iids` but not in `statuses` map (no error) - [ ] Issues with status widget but `status: null` → in `all_fetched_iids` but not in `statuses` map - [ ] GraphQL 404 → returns `Ok(FetchStatusResult)` with empty collections, `unsupported_reason == Some(UnsupportedReason::GraphqlEndpointMissing)`, + warning log - [ ] GraphQL 403 (`GitLabAuthFailed`) → returns `Ok(FetchStatusResult)` with empty collections, `unsupported_reason == Some(UnsupportedReason::AuthForbidden)`, + warning log ### AC-4: Migration 021 (Unit) - [ ] Migration adds 5 nullable columns to `issues`: `status_name TEXT`, `status_category TEXT`, `status_color TEXT`, `status_icon_name TEXT`, `status_synced_at INTEGER` (ms epoch UTC — when enrichment last wrote/cleared this row's status) - [ ] Adds compound index `idx_issues_project_status_name(project_id, status_name)` for `--status` filter performance - [ ] `LATEST_SCHEMA_VERSION` becomes 21 - [ ] Existing issues retain all data (ALTER TABLE ADD COLUMN is non-destructive) - [ ] In-memory DB test: after migration, `SELECT status_name, status_category, status_color, status_icon_name, status_synced_at FROM issues` succeeds - [ ] NULL default: existing rows have NULL for all 5 new columns ### AC-5: Config Toggle (Unit) - [ ] `SyncConfig` has `fetch_work_item_status: bool` field - [ ] Default value: `true` (uses existing `default_true()` helper) - [ ] JSON key: `"fetchWorkItemStatus"` (camelCase, matching convention) - [ ] Existing config files without this key → defaults to `true` (no breakage) ### AC-6: Enrichment in Orchestrator (Integration) - [ ] Enrichment runs after `ingest_issues()` completes, before discussion sync - [ ] Runs on every sync (not gated by `--full`) - [ ] Gated by `config.sync.fetch_work_item_status` — if `false`, skipped entirely - [ ] Creates `GraphqlClient` via `client.graphql_client()` factory (token stays encapsulated) - [ ] For each project: calls `fetch_issue_statuses()`, then UPDATEs matching `issues` rows - [ ] Enrichment DB writes are transactional per project (all-or-nothing) - [ ] Before applying updates, NULL out status fields for issues that were fetched but have no status widget (prevents stale status from lingering when status is removed) - [ ] If enrichment fails mid-project, prior persisted statuses are unchanged (transaction rollback) - [ ] UPDATE SQL: `SET status_name=?, status_category=?, status_color=?, status_icon_name=?, status_synced_at=? WHERE project_id=? AND iid=?` (synced_at = current epoch ms) - [ ] Clear SQL also sets `status_synced_at` to current epoch ms (records when we confirmed absence of status) - [ ] Logs summary: `"Enriched {n} issues with work item status for {project}"` — includes `seen`, `enriched`, `cleared`, `without_widget` counts - [ ] On any GraphQL error: logs warning, continues to next project (never fails the sync) - [ ] If project path lookup fails (DB error or missing row), status enrichment is skipped for that project with warning log and `status_enrichment_error: "project_path_missing"` — does NOT fail the overall project pipeline - [ ] `IngestProjectResult` gains `statuses_enriched: usize`, `statuses_cleared: usize`, `statuses_seen: usize`, `statuses_without_widget: usize`, `partial_error_count: usize`, `first_partial_error: Option`, and `status_enrichment_error: Option` (captures error message when enrichment fails for this project) - [ ] Progress events: `StatusEnrichmentComplete { enriched, cleared }` and `StatusEnrichmentSkipped` (when config toggle is false) - [ ] Enrichment log line includes `seen`, `enriched`, `cleared`, and `without_widget` counts for full observability ### AC-7: Show Issue Display (E2E) **Human (`lore issues 123`):** - [ ] New line after "State": `Status: In progress` (colored by `status_color` hex → nearest terminal color) - [ ] Status line only shown when `status_name IS NOT NULL` - [ ] Category shown in parens when available, lowercased: `Status: In progress (in_progress)` **Robot (`lore --robot issues 123`):** - [ ] JSON includes `status_name`, `status_category`, `status_color`, `status_icon_name`, `status_synced_at` fields - [ ] Fields are `null` (not absent) when status not available - [ ] `status_synced_at` is integer (ms epoch UTC) or `null` — enables freshness checks by consumers ### AC-8: List Issues Display (E2E) **Human (`lore list issues`):** - [ ] New "Status" column in table after "State" column - [ ] Status name colored by `status_color` hex → nearest terminal color - [ ] NULL status → empty cell (no placeholder text) **Robot (`lore --robot list issues`):** - [ ] JSON includes `status_name`, `status_category` fields on each issue - [ ] `--fields` supports: `status_name`, `status_category`, `status_color`, `status_icon_name`, `status_synced_at` - [ ] `--fields minimal` preset does NOT include status fields (keeps token count low) ### AC-9: List Issues Filter (E2E) - [ ] `lore list issues --status "In progress"` → only issues where `status_name = 'In progress'` - [ ] Filter uses case-insensitive matching (`COLLATE NOCASE`) for UX — `"in progress"` matches `"In progress"` - [ ] `--status` is repeatable: `--status "In progress" --status "To do"` → issues matching ANY of the given statuses (OR semantics within `--status`, AND with other filters) - [ ] Single `--status` produces `WHERE status_name = ? COLLATE NOCASE`; multiple produce `WHERE status_name IN (?, ?) COLLATE NOCASE` - [ ] `--status` combined with other filters (e.g., `--state opened --status "To do"`) → AND logic - [ ] `--status` with no matching issues → "No issues found." ### AC-10: Robot Sync Envelope (E2E) - [ ] `lore --robot sync` JSON response includes per-project `status_enrichment` object: `{ "mode": "fetched|unsupported|skipped", "reason": null | "graphql_endpoint_missing" | "auth_forbidden", "seen": N, "enriched": N, "cleared": N, "without_widget": N, "partial_errors": N, "first_partial_error": null | "message", "error": null | "message" }` - [ ] `mode: "fetched"` — enrichment ran normally (even if 0 statuses found) - [ ] `mode: "unsupported"` — GraphQL returned 404 or 403; `reason` explains why; all counters are 0 - [ ] `mode: "skipped"` — config toggle is off; all other fields are 0/null - [ ] When enrichment fails for a project: `error` contains the error message string, counters are 0, `mode` is `"fetched"` (it attempted) - [ ] `partial_errors` > 0 indicates GraphQL returned partial-data responses (data + errors) — agents can use this to flag potentially incomplete status data - [ ] `seen` counter enables agents to distinguish "project has 0 issues" from "project has 500 issues but 0 with status" - [ ] Aggregate `status_enrichment_errors: N` in top-level sync summary for quick agent health checks ### AC-11: Compiler & Quality Gates - [ ] `cargo check --all-targets` passes with zero errors - [ ] `cargo clippy --all-targets -- -D warnings` passes (pedantic + nursery enabled) - [ ] `cargo fmt --check` passes - [ ] `cargo test` passes — all new + existing tests green --- ## GitLab API Constraints ### Tier Requirement **Premium or Ultimate only.** The status widget (`WorkItemWidgetStatus`) lives entirely in GitLab EE. On Free tier, the widget simply won't appear in the `widgets` array — no error, just absent. ### Version Requirements | GitLab Version | Status Support | |---|---| | < 17.11 | No status widget at all | | 17.11 - 18.1 | Status widget (Experiment), `category` field missing | | 18.2 - 18.3 | Status widget with feature flag (enabled by default) | | 18.4+ | Generally available, `workItemAllowedStatuses` query added | | 18.5+ | Custom statuses via Lifecycles | ### Authentication GraphQL endpoint (`/api/graphql`) does **NOT** accept `PRIVATE-TOKEN` header. Must use: `Authorization: Bearer ` Same personal access token works — just a different header format. Requires `api` or `read_api` scope. ### Query Path **Must use `workItems` resolver, NOT `project.issues`.** The legacy `issues` field returns the old `Issue` type which does not include status widgets. ```graphql query($projectPath: ID!, $after: String, $first: Int!) { project(fullPath: $projectPath) { workItems(types: [ISSUE], first: $first, after: $after) { nodes { iid state widgets { __typename ... on WorkItemWidgetStatus { status { name category color iconName } } } } pageInfo { endCursor hasNextPage } } } } ``` ### GraphQL Limits | Limit | Value | |---|---| | Max complexity | 250 (authenticated) | | Max page size | 100 nodes | | Max query size | 10,000 chars | | Request timeout | 30 seconds | ### Status Values **System-defined statuses (default lifecycle):** | ID | Name | Color | Category | Maps to State | |----|------|-------|----------|---------------| | 1 | To do | `#737278` (gray) | `to_do` | open | | 2 | In progress | `#1f75cb` (blue) | `in_progress` | open | | 3 | Done | `#108548` (green) | `done` | closed | | 4 | Won't do | `#DD2B0E` (red) | `canceled` | closed | | 5 | Duplicate | `#DD2B0E` (red) | `canceled` | closed | **Known category values:** `TRIAGE`, `TO_DO`, `IN_PROGRESS`, `DONE`, `CANCELED` Note: Organizations with Premium/Ultimate on 18.5+ can define up to 70 custom statuses per namespace. Custom status names, IDs, and potentially category values will vary by instance. We store category as a raw string (`Option`) rather than an enum to avoid breaking on unknown values from custom lifecycles or future GitLab releases. ### Status-State Synchronization Setting a status in `done`/`canceled` category automatically closes the issue. Setting a status in `triage`/`to_do`/`in_progress` automatically reopens it. This is bidirectional — closing via REST sets status to `default_closed_status`. --- ## Implementation Detail ### File 1: `src/gitlab/graphql.rs` (NEW) **Purpose:** Minimal GraphQL client and status-specific fetcher. ```rust use std::collections::{HashMap, HashSet}; use reqwest::Client; use serde::Deserialize; use tracing::{debug, warn}; use crate::core::error::{LoreError, Result}; use super::types::WorkItemStatus; // ─── GraphQL Client ────────────────────────────────────────────────────────── pub struct GraphqlClient { http: Client, base_url: String, // e.g. "https://gitlab.example.com" token: String, } /// Result of a GraphQL query — includes both data and partial-error metadata. /// Partial errors occur when GraphQL returns both `data` and `errors` (per spec, /// this means some fields resolved successfully while others failed). pub struct GraphqlQueryResult { pub data: serde_json::Value, pub had_partial_errors: bool, pub first_partial_error: Option, } impl GraphqlClient { pub fn new(base_url: &str, token: &str) -> Self { let base_url = base_url.trim_end_matches('/').to_string(); let http = Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .expect("Failed to build HTTP client"); Self { http, base_url, token: token.to_string(), } } /// POST a GraphQL query and return a `GraphqlQueryResult`. /// Returns Err if HTTP fails, JSON parse fails, or response has `errors` with no `data`. /// If response has both `errors` and `data`, returns `GraphqlQueryResult` with /// `had_partial_errors=true` and `first_partial_error` populated /// (GraphQL spec permits partial results with errors). pub async fn query( &self, query: &str, variables: serde_json::Value, ) -> Result { let url = format!("{}/api/graphql", self.base_url); let body = serde_json::json!({ "query": query, "variables": variables, }); let response = self .http .post(&url) .header("Authorization", format!("Bearer {}", self.token)) .header("Content-Type", "application/json") .json(&body) .send() .await .map_err(|e| LoreError::Other(format!("GraphQL request failed: {e}")))?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { return Err(LoreError::GitLabAuthFailed); } if status == reqwest::StatusCode::NOT_FOUND { return Err(LoreError::GitLabNotFound { resource: "GraphQL endpoint".to_string(), }); } if status == reqwest::StatusCode::TOO_MANY_REQUESTS { let retry_after = response .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|v| { // Try delta-seconds first (e.g., "60"), then HTTP-date v.parse::().ok().or_else(|| { httpdate::parse_http_date(v) .ok() .and_then(|dt| dt.duration_since(std::time::SystemTime::now()).ok()) .map(|d| d.as_secs()) }) }) .unwrap_or(60); // LoreError::GitLabRateLimited uses u64, not Option return Err(LoreError::GitLabRateLimited { retry_after }); } if !status.is_success() { let body = response .text() .await .unwrap_or_else(|_| "unknown".to_string()); return Err(LoreError::Other(format!( "GraphQL HTTP {}: {}", status, body ))); } let json: serde_json::Value = response .json() .await .map_err(|e| LoreError::Other(format!("GraphQL response parse failed: {e}")))?; // Check for GraphQL-level errors let errors = json .get("errors") .and_then(|e| e.as_array()) .filter(|arr| !arr.is_empty()); let data = json.get("data").filter(|d| !d.is_null()).cloned(); if let Some(err_array) = errors { let first_msg = err_array[0] .get("message") .and_then(|m| m.as_str()) .unwrap_or("Unknown GraphQL error") .to_string(); if let Some(data) = data { // Partial data with errors — return data with error metadata warn!(error = %first_msg, "GraphQL returned partial data with errors"); return Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg), }); } // Errors only, no data return Err(LoreError::Other(format!("GraphQL error: {first_msg}"))); } data.map(|d| GraphqlQueryResult { data: d, had_partial_errors: false, first_partial_error: None, }) .ok_or_else(|| LoreError::Other("GraphQL response missing 'data' field".to_string())) } } // ─── Status Fetcher ────────────────────────────────────────────────────────── const ISSUE_STATUS_QUERY: &str = r#" query($projectPath: ID!, $after: String, $first: Int!) { project(fullPath: $projectPath) { workItems(types: [ISSUE], first: $first, after: $after) { nodes { iid widgets { __typename ... on WorkItemWidgetStatus { status { name category color iconName } } } } pageInfo { endCursor hasNextPage } } } } "#; /// Page sizes to try, in order, when adaptive fallback is triggered. const PAGE_SIZES: &[u32] = &[100, 50, 25, 10]; /// Deserialization types for the GraphQL response shape. /// These are private — only `WorkItemStatus` escapes via the return type. #[derive(Deserialize)] struct WorkItemsResponse { project: Option, } #[derive(Deserialize)] struct ProjectNode { #[serde(rename = "workItems")] work_items: Option, } #[derive(Deserialize)] struct WorkItemConnection { nodes: Vec, #[serde(rename = "pageInfo")] page_info: PageInfo, } #[derive(Deserialize)] struct WorkItemNode { iid: String, // GraphQL returns iid as String widgets: Vec, } #[derive(Deserialize)] struct PageInfo { #[serde(rename = "endCursor")] end_cursor: Option, #[serde(rename = "hasNextPage")] has_next_page: bool, } #[derive(Deserialize)] struct StatusWidget { status: Option, } /// Reason why status enrichment was not available for a project. /// Used in `FetchStatusResult` to distinguish /// "no statuses found" from "feature unavailable." #[derive(Debug, Clone)] pub enum UnsupportedReason { /// GraphQL endpoint returned 404 (old GitLab, self-hosted with GraphQL disabled) GraphqlEndpointMissing, /// GraphQL endpoint returned 403 (insufficient permissions) AuthForbidden, } /// Result of fetching issue statuses — includes both the status map and the /// set of all IIDs that were seen in the GraphQL response (with or without status). /// `all_fetched_iids` is a `HashSet` for O(1) staleness lookups in the orchestrator /// when NULLing out status fields for issues that no longer have a status widget. /// /// `unsupported_reason` is `Some(...)` when enrichment was skipped due to 404/403 — /// enables the orchestrator to distinguish "project has no statuses" from "feature /// unavailable" for observability and robot sync output. /// /// `partial_error_count` and `first_partial_error` track pages where GraphQL returned /// both `data` and `errors` — enables agents to detect potentially incomplete data. pub struct FetchStatusResult { pub statuses: HashMap, pub all_fetched_iids: HashSet, pub unsupported_reason: Option, pub partial_error_count: usize, pub first_partial_error: Option, } /// Returns true if a GraphQL error message suggests the query is too complex /// or timed out — conditions where reducing page size may help. fn is_complexity_or_timeout_error(msg: &str) -> bool { let lower = msg.to_ascii_lowercase(); lower.contains("complexity") || lower.contains("timeout") } /// Fetch work item statuses for all issues in a project. /// Returns a `FetchStatusResult` containing: /// - `statuses`: map of IID (i64) → WorkItemStatus for issues that have a status widget /// - `all_fetched_iids`: all IIDs seen in the GraphQL response as `HashSet` (for O(1) staleness clearing) /// - `unsupported_reason`: `Some(...)` when enrichment was skipped due to 404/403 /// - `partial_error_count` / `first_partial_error`: partial-error tracking across pages /// /// Paginates through all results. Uses adaptive page sizing: starts with 100, /// falls back to 50→25→10 on complexity/timeout errors. /// Includes a pagination guard: aborts if `hasNextPage=true` but cursor is /// `None` or unchanged from the previous page (prevents infinite loops from GraphQL bugs). /// /// On 404/403: returns Ok(FetchStatusResult) with empty maps + unsupported_reason + warning log. /// On other errors: returns Err. pub async fn fetch_issue_statuses( client: &GraphqlClient, project_path: &str, ) -> Result { let mut statuses = HashMap::new(); let mut all_fetched_iids = HashSet::new(); let mut cursor: Option = None; let mut page_size_idx = 0; // index into PAGE_SIZES let mut partial_error_count = 0usize; let mut first_partial_error: Option = None; loop { let current_page_size = PAGE_SIZES[page_size_idx]; let variables = serde_json::json!({ "projectPath": project_path, "after": cursor, "first": current_page_size, }); let query_result = match client.query(ISSUE_STATUS_QUERY, variables).await { Ok(result) => result, Err(LoreError::GitLabNotFound { .. }) => { warn!( project = project_path, "GraphQL endpoint not found — skipping status enrichment" ); return Ok(FetchStatusResult { statuses, all_fetched_iids, unsupported_reason: Some(UnsupportedReason::GraphqlEndpointMissing), partial_error_count, first_partial_error, }); } Err(LoreError::GitLabAuthFailed) => { warn!( project = project_path, "GraphQL auth failed — skipping status enrichment" ); return Ok(FetchStatusResult { statuses, all_fetched_iids, unsupported_reason: Some(UnsupportedReason::AuthForbidden), partial_error_count, first_partial_error, }); } Err(LoreError::Other(ref msg)) if is_complexity_or_timeout_error(msg) => { // Adaptive page size: try smaller page if page_size_idx + 1 < PAGE_SIZES.len() { let old_size = PAGE_SIZES[page_size_idx]; page_size_idx += 1; let new_size = PAGE_SIZES[page_size_idx]; warn!( project = project_path, old_size, new_size, "GraphQL complexity/timeout error — reducing page size and retrying" ); continue; // retry same cursor with smaller page } // Smallest page size still failed return Err(LoreError::Other(msg.clone())); } Err(e) => return Err(e), }; // Track partial-error metadata if query_result.had_partial_errors { partial_error_count += 1; if first_partial_error.is_none() { first_partial_error = query_result.first_partial_error.clone(); } } let response: WorkItemsResponse = serde_json::from_value(query_result.data) .map_err(|e| LoreError::Other(format!("Failed to parse GraphQL response: {e}")))?; let connection = match response .project .and_then(|p| p.work_items) { Some(c) => c, None => { debug!( project = project_path, "No workItems in GraphQL response" ); break; } }; for node in &connection.nodes { if let Ok(iid) = node.iid.parse::() { all_fetched_iids.insert(iid); // Find the status widget via __typename for deterministic matching for widget_value in &node.widgets { let is_status_widget = widget_value .get("__typename") .and_then(|t| t.as_str()) == Some("WorkItemWidgetStatus"); if is_status_widget { if let Ok(sw) = serde_json::from_value::(widget_value.clone()) && let Some(status) = sw.status { statuses.insert(iid, status); } } } } } // After a successful page, reset page size back to max for next page // (the complexity issue may be cursor-position-specific) page_size_idx = 0; if connection.page_info.has_next_page { let new_cursor = connection.page_info.end_cursor; // Pagination guard: abort if cursor is None or unchanged (prevents infinite loops) if new_cursor.is_none() || new_cursor == cursor { warn!( project = project_path, pages_fetched = all_fetched_iids.len(), "Pagination cursor stalled (hasNextPage=true but cursor unchanged) — returning partial result" ); break; } cursor = new_cursor; } else { break; } } debug!( project = project_path, count = statuses.len(), total_fetched = all_fetched_iids.len(), partial_error_count, "Fetched issue statuses via GraphQL" ); Ok(FetchStatusResult { statuses, all_fetched_iids, unsupported_reason: None, partial_error_count, first_partial_error, }) } /// Map RGB to nearest ANSI 256-color index using the 6x6x6 color cube (indices 16-231). /// NOTE: clippy::items_after_test_module — this MUST be placed BEFORE the test module. pub fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 { let ri = ((u16::from(r) * 5 + 127) / 255) as u8; let gi = ((u16::from(g) * 5 + 127) / 255) as u8; let bi = ((u16::from(b) * 5 + 127) / 255) as u8; 16 + 36 * ri + 6 * gi + bi } #[cfg(test)] mod tests { use super::*; // Tests use wiremock or similar mock server // See TDD Plan section for test specifications } ``` ### File 2: `src/gitlab/mod.rs` (MODIFY) **Existing code (14 lines):** ```rust pub mod client; pub mod transformers; pub mod types; ``` **Add after `pub mod types;`:** ```rust pub mod graphql; ``` **Add to `pub use types::{...}` list:** ```rust WorkItemStatus, ``` ### File 3: `src/gitlab/types.rs` (MODIFY) **Add at end of file (after `GitLabMergeRequest` struct, before any `#[cfg(test)]`):** ```rust /// Work item status from GitLab GraphQL API. /// Stored in the `issues` table columns: status_name, status_category, status_color, status_icon_name. /// /// `category` is stored as a raw string from GraphQL (e.g., "IN_PROGRESS", "TO_DO", "DONE"). /// No enum — custom statuses on GitLab 18.5+ can have arbitrary category values, and even /// system-defined categories may change across GitLab versions. Storing the raw string avoids /// serde deserialization failures on unknown values. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkItemStatus { pub name: String, pub category: Option, pub color: Option, #[serde(rename = "iconName")] pub icon_name: Option, } ``` ### File 4: `src/core/db.rs` (MODIFY) **Existing pattern — each migration is a tuple of `(&str, &str)` loaded via `include_str!`:** ```rust const MIGRATIONS: &[(&str, &str)] = &[ ("001", include_str!("../../migrations/001_initial.sql")), // ... 002 through 020 ... ("020", include_str!("../../migrations/020_mr_diffs_watermark.sql")), ]; ``` **Add as the 21st entry at the end of the `MIGRATIONS` array:** ```rust ("021", include_str!("../../migrations/021_work_item_status.sql")), ``` `LATEST_SCHEMA_VERSION` is computed as `MIGRATIONS.len() as i32` — automatically becomes 21. ### File 5: `src/core/config.rs` (MODIFY) **In `SyncConfig` struct, add after `fetch_mr_file_changes`:** ```rust #[serde(rename = "fetchWorkItemStatus", default = "default_true")] pub fetch_work_item_status: bool, ``` **In `impl Default for SyncConfig`, add after `fetch_mr_file_changes: true`:** ```rust fetch_work_item_status: true, ``` ### File 6: `src/ingestion/orchestrator.rs` (MODIFY) **Existing `ingest_project_issues_with_progress` flow (simplified):** ``` Phase 1: ingest_issues() Phase 2: sync_discussions() ← discussions for changed issues Phase 3: drain_resource_events() ← if config.sync.fetch_resource_events Phase 4: extract_refs_from_state_events() ``` **Insert new Phase 1.5 between issue ingestion and discussion sync.** The orchestrator function receives a `&GitLabClient`. Use the new `client.graphql_client()` factory (File 13) to get a ready-to-use GraphQL client without exposing the raw token. The function has `project_id: i64` but needs `path_with_namespace` for GraphQL — look it up from DB. **The path lookup uses `.optional()?` to make failure non-fatal** — if the project path is missing or the query fails, enrichment is skipped with a structured error rather than failing the entire project pipeline: ```rust // ── Phase 1.5: GraphQL Status Enrichment ──────────────────────────────── if config.sync.fetch_work_item_status && !signal.is_cancelled() { // Get project path for GraphQL query (orchestrator only has project_id). // Non-fatal: if path is missing, skip enrichment with structured error. let project_path: Option = conn .query_row( "SELECT path_with_namespace FROM projects WHERE id = ?1", [project_id], |r| r.get(0), ) .optional()?; let Some(project_path) = project_path else { warn!(project_id, "Project path not found — skipping status enrichment"); result.status_enrichment_error = Some("project_path_missing".to_string()); result.status_enrichment_mode = "fetched".to_string(); // attempted but unavailable emit(ProgressEvent::StatusEnrichmentComplete { enriched: 0, cleared: 0 }); // Fall through to discussion sync — enrichment failure is non-fatal }; if let Some(ref project_path) = project_path { let graphql_client = client.graphql_client(); // factory keeps token encapsulated match crate::gitlab::graphql::fetch_issue_statuses( &graphql_client, project_path, ) .await { Ok(fetch_result) => { // Record unsupported reason for robot sync output result.status_enrichment_mode = match &fetch_result.unsupported_reason { Some(crate::gitlab::graphql::UnsupportedReason::GraphqlEndpointMissing) => { result.status_unsupported_reason = Some("graphql_endpoint_missing".to_string()); "unsupported".to_string() } Some(crate::gitlab::graphql::UnsupportedReason::AuthForbidden) => { result.status_unsupported_reason = Some("auth_forbidden".to_string()); "unsupported".to_string() } None => "fetched".to_string(), }; // Coverage telemetry result.statuses_seen = fetch_result.all_fetched_iids.len(); result.partial_error_count = fetch_result.partial_error_count; result.first_partial_error = fetch_result.first_partial_error.clone(); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; let (enriched, cleared) = enrich_issue_statuses_txn( conn, project_id, &fetch_result.statuses, &fetch_result.all_fetched_iids, now_ms, )?; result.statuses_enriched = enriched; result.statuses_cleared = cleared; result.statuses_without_widget = result.statuses_seen .saturating_sub(enriched); info!( project = %project_path, seen = result.statuses_seen, enriched, cleared, without_widget = result.statuses_without_widget, partial_errors = result.partial_error_count, "Issue status enrichment complete" ); } Err(e) => { let msg = format!("{e}"); warn!( project = %project_path, error = %msg, "GraphQL status enrichment failed — continuing without status data" ); result.status_enrichment_error = Some(msg); result.status_enrichment_mode = "fetched".to_string(); // it attempted } } emit(ProgressEvent::StatusEnrichmentComplete { enriched: result.statuses_enriched, cleared: result.statuses_cleared, }); } } else { result.status_enrichment_mode = "skipped".to_string(); emit(ProgressEvent::StatusEnrichmentSkipped); } ``` **New helper function in `orchestrator.rs` — transactional with staleness clearing:** ```rust /// Apply status enrichment within a transaction. Two phases: /// 1. NULL out status fields for issues that were fetched but have no status widget /// (prevents stale status from lingering when a status is removed in GitLab) /// 2. Apply new status values for issues that do have a status widget /// /// Both phases write `status_synced_at` to record when enrichment last touched each row. /// If anything fails, the entire transaction rolls back — no partial updates. fn enrich_issue_statuses_txn( conn: &Connection, project_id: i64, statuses: &HashMap, all_fetched_iids: &HashSet, now_ms: i64, ) -> Result<(usize, usize)> { let tx = conn.unchecked_transaction()?; // Phase 1: Clear stale statuses for fetched issues that no longer have a status widget let mut clear_stmt = tx.prepare_cached( "UPDATE issues SET status_name = NULL, status_category = NULL, status_color = NULL, status_icon_name = NULL, status_synced_at = ?3 WHERE project_id = ?1 AND iid = ?2 AND status_name IS NOT NULL", )?; let mut cleared = 0usize; for iid in all_fetched_iids { if !statuses.contains_key(iid) { let rows = clear_stmt.execute(rusqlite::params![project_id, iid, now_ms])?; if rows > 0 { cleared += 1; } } } // Phase 2: Apply new status values let mut update_stmt = tx.prepare_cached( "UPDATE issues SET status_name = ?1, status_category = ?2, status_color = ?3, status_icon_name = ?4, status_synced_at = ?5 WHERE project_id = ?6 AND iid = ?7", )?; let mut enriched = 0; for (iid, status) in statuses { let rows = update_stmt.execute(rusqlite::params![ &status.name, &status.category, &status.color, &status.icon_name, now_ms, project_id, iid, ])?; if rows > 0 { enriched += 1; } } tx.commit()?; Ok((enriched, cleared)) } ``` **Modify `IngestProjectResult` — add fields:** ```rust pub statuses_enriched: usize, pub statuses_cleared: usize, pub statuses_seen: usize, // total IIDs in GraphQL response pub statuses_without_widget: usize, // seen - enriched (coverage metric) pub partial_error_count: usize, // pages with partial-data responses pub first_partial_error: Option, // first partial-error message pub status_enrichment_error: Option, pub status_enrichment_mode: String, // "fetched" | "unsupported" | "skipped" pub status_unsupported_reason: Option, // "graphql_endpoint_missing" | "auth_forbidden" ``` **Modify `ProgressEvent` enum — add variants:** ```rust StatusEnrichmentComplete { enriched: usize, cleared: usize }, StatusEnrichmentSkipped, // emitted when config.sync.fetch_work_item_status is false ``` ### File 7: `src/cli/commands/show.rs` (MODIFY) **Modify `IssueRow` (private struct in show.rs) — add 5 fields:** ```rust status_name: Option, status_category: Option, status_color: Option, status_icon_name: Option, status_synced_at: Option, ``` **Modify BOTH `find_issue` SQL queries (with and without project filter) — add 5 columns to SELECT:** Note: `find_issue()` has two separate SQL strings — one for when `project_filter` is `Some` and one for `None`. Both must be updated identically. ```sql 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, i.due_date, i.milestone_title, i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at ``` Column indices: `status_name=12, status_category=13, status_color=14, status_icon_name=15, status_synced_at=16` **Modify `find_issue` row mapping — add after `milestone_title: row.get(11)?`:** ```rust 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)?, ``` **Modify `IssueDetail` (public struct) — add 5 fields:** ```rust pub status_name: Option, pub status_category: Option, pub status_color: Option, pub status_icon_name: Option, pub status_synced_at: Option, ``` **Modify `run_show_issue` return — add fields from `issue` row:** ```rust 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, ``` **Modify `print_show_issue` — add after the "State:" line (currently ~line 604):** ```rust if let Some(status) = &issue.status_name { let status_display = if let Some(cat) = &issue.status_category { format!("{status} ({})", cat.to_ascii_lowercase()) } else { status.clone() }; println!( "Status: {}", style_with_hex(&status_display, issue.status_color.as_deref()) ); } ``` **New helper function for hex color → terminal color:** ```rust fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> { let Some(hex) = hex else { return style(text); }; let hex = hex.trim_start_matches('#'); // NOTE: clippy::collapsible_if — must combine conditions if hex.len() == 6 && let (Ok(r), Ok(g), Ok(b)) = ( u8::from_str_radix(&hex[0..2], 16), u8::from_str_radix(&hex[2..4], 16), u8::from_str_radix(&hex[4..6], 16), ) { return style(text).color256(crate::gitlab::graphql::ansi256_from_rgb(r, g, b)); } style(text) } ``` **Modify `IssueDetailJson` — add 5 fields:** ```rust pub status_name: Option, pub status_category: Option, pub status_color: Option, pub status_icon_name: Option, pub status_synced_at: Option, ``` **Modify `From<&IssueDetail> for IssueDetailJson` — add after existing field mappings:** ```rust status_name: d.status_name.clone(), status_category: d.status_category.clone(), status_color: d.status_color.clone(), status_icon_name: d.status_icon_name.clone(), status_synced_at: d.status_synced_at, ``` ### File 8: `src/cli/commands/list.rs` (MODIFY) **Modify `IssueListRow` — add 5 fields:** ```rust pub status_name: Option, pub status_category: Option, pub status_color: Option, pub status_icon_name: Option, pub status_synced_at: Option, ``` **Modify `IssueListRowJson` — add 5 fields (all for robot mode):** ```rust pub status_name: Option, pub status_category: Option, pub status_color: Option, pub status_icon_name: Option, pub status_synced_at: Option, ``` **Modify `From<&IssueListRow> for IssueListRowJson` — add after existing field mappings:** ```rust status_name: r.status_name.clone(), status_category: r.status_category.clone(), status_color: r.status_color.clone(), status_icon_name: r.status_icon_name.clone(), status_synced_at: r.status_synced_at, ``` **Modify `query_issues` SELECT — add 5 columns after `unresolved_count` subquery:** ```sql i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at ``` **Modify `query_issues` row mapping — add after `unresolved_count: row.get(11)?`:** The existing `query_issues` SELECT has 12 columns (indices 0-11). The 5 new status columns append as indices 12-16: ```rust 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)?, ``` **Modify `ListFilters` — add status filter:** ```rust pub statuses: &'a [String], ``` **Modify `query_issues` WHERE clause builder — add after `has_due_date` block:** ```rust if !filters.statuses.is_empty() { if filters.statuses.len() == 1 { where_clauses.push("i.status_name = ? COLLATE NOCASE"); params.push(Box::new(filters.statuses[0].clone())); } else { // Build IN clause: "i.status_name IN (?, ?, ?) COLLATE NOCASE" let placeholders = vec!["?"; filters.statuses.len()].join(", "); where_clauses.push(format!("i.status_name IN ({placeholders}) COLLATE NOCASE")); for s in filters.statuses { params.push(Box::new(s.clone())); } } } ``` **Modify `print_list_issues` table — add "Status" column header between "State" and "Assignee":** Current column order: IID, Title, State, Assignee, Labels, Disc, Updated New column order: IID, Title, State, **Status**, Assignee, Labels, Disc, Updated ```rust Cell::new("Status").add_attribute(Attribute::Bold), ``` **Modify `print_list_issues` row — add status cell after state cell (before assignee cell):** ```rust let status_cell = match &issue.status_name { Some(status) => colored_cell_hex(status, issue.status_color.as_deref()), None => Cell::new(""), }; ``` **New helper function for hex → `comfy_table::Color`:** ```rust fn colored_cell_hex(content: impl std::fmt::Display, hex: Option<&str>) -> Cell { let Some(hex) = hex else { return Cell::new(content); }; if !console::colors_enabled() { return Cell::new(content); } let hex = hex.trim_start_matches('#'); // NOTE: clippy::collapsible_if — must combine conditions if hex.len() == 6 && let (Ok(r), Ok(g), Ok(b)) = ( u8::from_str_radix(&hex[0..2], 16), u8::from_str_radix(&hex[2..4], 16), u8::from_str_radix(&hex[4..6], 16), ) { return Cell::new(content).fg(Color::Rgb { r, g, b }); } Cell::new(content) } ``` ### File 9: `src/cli/mod.rs` (MODIFY) **In `IssuesArgs` struct, add `--status` flag (after `--milestone` or similar filter flags):** ```rust /// Filter by work item status name (e.g., "In progress"). Repeatable for OR semantics. #[arg(long, help_heading = "Filters")] pub status: Vec, ``` ### File 10: `src/main.rs` (MODIFY) **In `handle_issues` function (~line 695), add `statuses` to `ListFilters` construction:** ```rust let filters = ListFilters { // ... existing fields ... statuses: &args.status, }; ``` **In legacy `List` command handler (~line 2421), also add `statuses: &[]` to `ListFilters`:** ```rust let filters = ListFilters { // ... existing fields ... statuses: &[], // legacy command has no --status flag }; ``` ### File 11: `src/cli/autocorrect.rs` (MODIFY) **In `COMMAND_FLAGS` array (~line 52), add `"--status"` to the `"issues"` entry:** ```rust ( "issues", &[ // ... existing flags ... "--has-due", "--no-has-due", "--status", // <-- ADD THIS "--sort", // ... ], ), ``` The `registry_covers_command_flags` test validates all clap flags are registered here — it will fail if `--status` is missing. ### File 12: `src/cli/commands/ingest.rs` (MODIFY) **In the `ProgressEvent` match within the progress callback, add new arms:** ```rust ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => { // handle progress display } ProgressEvent::StatusEnrichmentSkipped => { // handle skipped display (config toggle off) } ``` The existing match is exhaustive — adding new variants to the enum without adding these arms will cause a compile error. ### File 13: `src/gitlab/client.rs` (MODIFY) **Add `GraphqlClient` factory — keeps token encapsulated (no raw accessor):** ```rust /// Create a GraphQL client using the same base URL and token as this REST client. /// The token is not exposed — only a ready-to-use GraphQL client is returned. pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient { crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token) } ``` ### File 14: `migrations/021_work_item_status.sql` (NEW) ```sql ALTER TABLE issues ADD COLUMN status_name TEXT; ALTER TABLE issues ADD COLUMN status_category TEXT; ALTER TABLE issues ADD COLUMN status_color TEXT; ALTER TABLE issues ADD COLUMN status_icon_name TEXT; ALTER TABLE issues ADD COLUMN status_synced_at INTEGER; CREATE INDEX IF NOT EXISTS idx_issues_project_status_name ON issues(project_id, status_name); ``` --- ## Migration Numbering Current state: migrations 001-020 exist on disk and in `MIGRATIONS` array. This feature uses **migration 021**. ## Files Changed (Summary) | File | Change | Lines (est) | |---|---|---| | `migrations/021_work_item_status.sql` | **NEW** — 5 ALTER TABLE ADD COLUMN + 1 index | 8 | | `src/gitlab/graphql.rs` | **NEW** — GraphQL client (with `GraphqlQueryResult` struct, partial-error metadata, HTTP-date Retry-After) + status fetcher + `FetchStatusResult` (HashSet, partial-error counters) + `UnsupportedReason` enum + adaptive page sizing + pagination guard + `ansi256_from_rgb` + `__typename` matching | ~380 | | `src/gitlab/mod.rs` | Add `pub mod graphql;` + re-exports | +3 | | `src/gitlab/types.rs` | Add `WorkItemStatus` struct (no enum — category is `Option`) | +15 | | `src/gitlab/client.rs` | Add `pub fn graphql_client()` factory | +5 | | `src/core/db.rs` | Add migration 021 to `MIGRATIONS` array | +5 | | `src/core/config.rs` | Add `fetch_work_item_status` to `SyncConfig` + Default | +4 | | `src/ingestion/orchestrator.rs` | Enrichment step (non-fatal path lookup) + `enrich_issue_statuses_txn()` (with `now_ms` param, returns enriched+cleared) + coverage telemetry (`seen`, `without_widget`, `partial_error_count`, `first_partial_error`) + `status_enrichment_error` + `status_enrichment_mode` + `status_unsupported_reason` on result + progress fields | +125 | | `src/cli/commands/show.rs` | 5 fields on `IssueRow`/`IssueDetail`/`IssueDetailJson`, display, hex color helper | +45 | | `src/cli/commands/list.rs` | 5 fields on row types, SQL, column, repeatable `--status` filter with IN clause, hex color helper | +50 | | `src/cli/commands/ingest.rs` | Add `StatusEnrichmentComplete` + `StatusEnrichmentSkipped` match arms to progress callback | +6 | | `src/cli/mod.rs` | `--status` flag on `IssuesArgs` (`Vec` for repeatable) | +3 | | `src/cli/autocorrect.rs` | Add `"--status"` to `COMMAND_FLAGS` issues entry | +1 | | `src/main.rs` | Wire `statuses` into both `ListFilters` constructions | +2 | **Total: ~650 lines new/modified across 14 files (2 new, 12 modified)** ## TDD Plan ### RED Phase — Tests to Write First 1. **`test_graphql_query_success`** — mock HTTP server returns `{"data":{"foo":"bar"}}` → `Ok(GraphqlQueryResult { data: json!({"foo":"bar"}), had_partial_errors: false, first_partial_error: None })` 2. **`test_graphql_query_with_errors_no_data`** — mock returns `{"errors":[{"message":"bad"}]}` (no `data` field) → `Err(LoreError::Other("GraphQL error: bad"))` 3. **`test_graphql_auth_uses_bearer`** — mock asserts request has `Authorization: Bearer tok123` header 4. **`test_graphql_401_maps_to_auth_failed`** — mock returns 401 → `Err(LoreError::GitLabAuthFailed)` 5. **`test_graphql_403_maps_to_auth_failed`** — mock returns 403 → `Err(LoreError::GitLabAuthFailed)` 6. **`test_graphql_404_maps_to_not_found`** — mock returns 404 → `Err(LoreError::GitLabNotFound)` 7. **`test_work_item_status_deserialize`** — parse `{"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}` → category is `Some("IN_PROGRESS")` 8. **`test_work_item_status_optional_fields`** — parse `{"name":"To do"}` → category/color/icon_name are None 9. **`test_work_item_status_unknown_category`** — parse `{"name":"Custom","category":"SOME_FUTURE_VALUE"}` → category is `Some("SOME_FUTURE_VALUE")` (no deserialization error) 10. **`test_work_item_status_null_category`** — parse `{"name":"In progress","category":null}` → category is None 11. **`test_fetch_statuses_pagination`** — mock returns 2 pages → all statuses in map, all_fetched_iids includes all IIDs 12. **`test_fetch_statuses_no_status_widget`** — mock returns widgets without StatusWidget → empty statuses map, all_fetched_iids still populated 13. **`test_fetch_statuses_404_graceful`** — mock returns 404 → `Ok(FetchStatusResult)` with empty maps, `unsupported_reason == Some(GraphqlEndpointMissing)` 14. **`test_fetch_statuses_403_graceful`** — mock returns 403 → `Ok(FetchStatusResult)` with empty maps, `unsupported_reason == Some(AuthForbidden)` 15. **`test_migration_021_adds_columns`** — in-memory DB: `PRAGMA table_info(issues)` includes 5 new columns (including `status_synced_at`) 16. **`test_migration_021_adds_index`** — in-memory DB: `PRAGMA index_list(issues)` includes `idx_issues_project_status_name` 17. **`test_enrich_issue_statuses_txn`** — insert issue, call `enrich_issue_statuses_txn()`, verify 4 status columns populated + `status_synced_at` is set to `now_ms` 18. **`test_enrich_skips_unknown_iids`** — status map has IID not in DB → no error, returns 0 19. **`test_enrich_clears_removed_status`** — issue previously had status, now in `all_fetched_iids` but not in `statuses` → status fields NULLed out, `status_synced_at` updated to `now_ms` (not NULLed — confirms we checked this row) 20. **`test_enrich_transaction_rolls_back_on_failure`** — simulate failure mid-enrichment → no partial updates, prior status values intact 21. **`test_list_filter_by_status`** — insert 2 issues with different statuses, filter returns correct one 22. **`test_list_filter_by_status_case_insensitive`** — `--status "in progress"` matches `"In progress"` via COLLATE NOCASE 23. **`test_config_fetch_work_item_status_default_true`** — `SyncConfig::default().fetch_work_item_status == true` 24. **`test_config_deserialize_without_key`** — JSON without `fetchWorkItemStatus` → defaults to `true` 25. **`test_ansi256_from_rgb`** — known conversions: `(0,0,0)→16`, `(255,255,255)→231`, `(31,117,203)→~68` 26. **`test_enrich_idempotent_across_two_runs`** — run `enrich_issue_statuses_txn()` twice with same data → columns unchanged, `enriched` count same both times 27. **`test_typename_matching_ignores_non_status_widgets`** — widgets array with `__typename: "WorkItemWidgetDescription"` → not parsed as status, no error 28. **`test_retry_after_http_date_format`** — mock returns 429 with `Retry-After: Wed, 11 Feb 2026 01:00:00 GMT` → parses to delta seconds from now 29. **`test_retry_after_invalid_falls_back_to_60`** — mock returns 429 with `Retry-After: garbage` → falls back to 60 30. **`test_enrich_sets_synced_at_on_clear`** — issue with status cleared → `status_synced_at` is `now_ms` (not NULL) 31. **`test_enrichment_error_captured_in_result`** — simulate GraphQL error → `IngestProjectResult.status_enrichment_error` contains error message string 32. **`test_robot_sync_includes_status_enrichment`** — robot sync output JSON includes per-project `status_enrichment` object with `mode`, `reason`, `seen`, `enriched`, `cleared`, `without_widget`, `partial_errors`, `first_partial_error`, `error` fields 33. **`test_graphql_partial_data_with_errors_returns_data`** — mock returns `{"data":{"foo":"bar"},"errors":[{"message":"partial failure"}]}` → `Ok(GraphqlQueryResult { data: json!({"foo":"bar"}), had_partial_errors: true, first_partial_error: Some("partial failure") })` 34. **`test_fetch_statuses_cursor_stall_aborts`** — mock returns `hasNextPage: true` with same `endCursor` on consecutive pages → pagination aborts with warning, returns partial result collected so far 35. **`test_fetch_statuses_unsupported_reason_none_on_success`** — successful fetch → `unsupported_reason` is `None` 36. **`test_fetch_statuses_complexity_error_reduces_page_size`** — mock returns complexity error on first page with `first=100` → retries with `first=50`, succeeds → result contains all statuses from the successful page 37. **`test_fetch_statuses_timeout_error_reduces_page_size`** — mock returns timeout error on first page with `first=100` → retries with smaller page size, succeeds 38. **`test_fetch_statuses_smallest_page_still_fails`** — mock returns complexity error at all page sizes (100→50→25→10) → returns Err 39. **`test_fetch_statuses_page_size_resets_after_success`** — first page succeeds at 100, second page fails at 100 → falls back to 50 for page 2, page 3 retries at 100 (reset after success) 40. **`test_list_filter_by_multiple_statuses`** — `--status "In progress" --status "To do"` → returns issues matching either status 41. **`test_project_path_missing_skips_enrichment`** — project with no `path_with_namespace` in DB → enrichment skipped, `status_enrichment_error == "project_path_missing"`, sync continues 42. **`test_fetch_statuses_partial_errors_tracked`** — mock returns partial-data response on one page → `partial_error_count == 1`, `first_partial_error` populated ### GREEN Phase — Build Order **Batch 1: Types + Migration** (Files 3, 4, 14 → Tests 7-10, 15-16, 23-24) - Create `migrations/021_work_item_status.sql` (5 columns + index) - Add `WorkItemStatus` struct to `types.rs` (no enum — category is `Option`) - Register migration 021 in `db.rs` - Add `fetch_work_item_status` to `config.rs` - Run: `cargo test test_work_item_status test_migration_021 test_config` **Batch 2: GraphQL Client** (Files 1, 2, 13 → Tests 1-6, 11-14, 25, 27-29, 33-39, 42) - Create `src/gitlab/graphql.rs` with full client (`GraphqlQueryResult` struct, partial-error metadata, HTTP-date Retry-After) + fetcher + `FetchStatusResult` (with `partial_error_count`/`first_partial_error`) + `UnsupportedReason` + adaptive page sizing + pagination guard + `ansi256_from_rgb` - Add `pub mod graphql;` to `gitlab/mod.rs` - Add `pub fn graphql_client()` factory to `client.rs` - Add `httpdate` crate to `Cargo.toml` dependencies (for Retry-After HTTP-date parsing) - Run: `cargo test graphql` **Batch 3: Orchestrator** (Files 6, 12 → Tests 17-20, 26, 30-32, 41) - Add enrichment phase (with non-fatal path lookup) + `enrich_issue_statuses_txn()` (with `now_ms` param) to orchestrator - Add coverage telemetry fields (`statuses_seen`, `statuses_without_widget`, `partial_error_count`, `first_partial_error`) to `IngestProjectResult` - Add `status_enrichment_error: Option`, `status_enrichment_mode: String`, `status_unsupported_reason: Option` to `IngestProjectResult` - Add `StatusEnrichmentComplete` + `StatusEnrichmentSkipped` to `ProgressEvent` enum - Add match arms in `ingest.rs` progress callback - Run: `cargo test orchestrator` **Batch 4: CLI Display + Filter** (Files 7-11 → Tests 21-22, 40) - Add status fields (including `status_synced_at`) to `show.rs` structs, SQL, display - Add status fields to `list.rs` structs, SQL, column, repeatable `--status` filter (with IN clause + COLLATE NOCASE) - Add `--status` flag to `cli/mod.rs` as `Vec` (repeatable) - Add `"--status"` to autocorrect registry - Wire `statuses` in both `ListFilters` constructions in `main.rs` - Wire `status_enrichment` (with `mode`/`reason`/`seen`/`without_widget`/`partial_errors`/`first_partial_error` fields) into robot sync output envelope - Run: `cargo test` (full suite) **Batch 5: Quality Gates** (AC-11) - `cargo check --all-targets` - `cargo clippy --all-targets -- -D warnings` - `cargo fmt --check` - `cargo test` (all green) **Key gotcha per batch:** - Batch 1: Migration has 5 columns now (including `status_synced_at INTEGER`), not 4 — test assertion must check for all 5 - Batch 2: Use `r##"..."##` in tests containing `"#1f75cb"` hex colors; `FetchStatusResult` is not `Clone` — tests must check fields individually; `__typename` test mock data must include the field in widget JSON objects; `httpdate` crate needed for Retry-After HTTP-date parsing — add to `Cargo.toml`; pagination guard test needs mock that returns same `endCursor` twice; partial-data test needs mock that returns both `data` and `errors` fields; adaptive page size tests need mock that inspects `$first` variable in request body to return different responses per page size; `GraphqlQueryResult` (not raw `Value`) is now the return type — test assertions must destructure it - Batch 3: Progress callback in `ingest.rs` must be updated in same batch as enum change (2 new arms: `StatusEnrichmentComplete` + `StatusEnrichmentSkipped`); `unchecked_transaction()` needed because `conn` is `&Connection` not `&mut Connection`; `enrich_issue_statuses_txn` takes 5 params now (added `now_ms: i64`) and returns `(usize, usize)` tuple — destructure at call site; `status_enrichment_error` must be populated on the `Err` branch; `status_enrichment_mode` and `status_unsupported_reason` must be set in all code paths; project path lookup uses `.optional()?` — requires `use rusqlite::OptionalExtension;` import - Batch 4: Autocorrect registry must be updated in same batch as clap flag addition; `COLLATE NOCASE` applies to the comparison, not the column definition; `status_synced_at` is `Option` in Rust structs (maps to nullable INTEGER in SQLite); `--status` is `Vec` not `Option` — `ListFilters.statuses` is `&[String]`; multi-value filter uses dynamic IN clause with placeholder generation ## Edge Cases - **GitLab Free tier**: Status widget absent → columns stay NULL, no error - **GitLab < 17.11**: No status widget at all → same as Free tier - **GitLab 17.11-18.0**: Status widget present but `category` field missing → store name only - **Custom statuses (18.5+)**: Names and categories won't match system defaults → stored as raw strings, no deserialization failures - **Token with `read_api` scope**: Should work for GraphQL queries (read-only) - **Self-hosted with GraphQL disabled**: 404 on endpoint → graceful skip, `unsupported_reason: GraphqlEndpointMissing` - **HTTP 403 (Forbidden)**: Treated as auth failure, graceful skip with warning log, `unsupported_reason: AuthForbidden` - **Rate limiting**: Respect `Retry-After` header if present - **Large projects (10k+ issues)**: Pagination handles this — adaptive page sizing starts at 100, falls back to 50→25→10 on complexity/timeout errors - **Self-hosted with strict complexity limits**: Adaptive page sizing automatically reduces `first` parameter to avoid complexity budget exhaustion - **Status-state sync**: Closed via REST → status might be "Done" — expected and correct - **Concurrent syncs**: Status enrichment is idempotent — UPDATE is safe to run multiple times - **Status removed in GitLab**: Issue was fetched but has no status widget → status fields NULLed out (staleness clearing) - **Enrichment failure mid-project**: Transaction rolls back — no partial updates, prior status values intact. Error message captured in `IngestProjectResult.status_enrichment_error` and surfaced in robot sync output. - **Project path missing from DB**: Enrichment skipped with structured error (`project_path_missing`), does NOT fail the overall project pipeline — sync continues to discussion phase - **Retry-After with HTTP-date format**: Parsed to delta-seconds from now via `httpdate` crate. Invalid format falls back to 60s. - **NULL hex color**: `style_with_hex` and `colored_cell_hex` fall back to unstyled text - **Invalid hex color**: Malformed color string → fall back to unstyled text - **Empty project**: `fetch_issue_statuses` returns empty result → no UPDATEs, no error - **Case-insensitive filter**: `--status "in progress"` matches `"In progress"` via COLLATE NOCASE - **Multiple status filter**: `--status "To do" --status "In progress"` → OR semantics within status, AND with other filters - **GraphQL partial-data response**: Response has both `data` and `errors` → data is used, `partial_error_count` incremented, `first_partial_error` captured — agents can detect incomplete data via robot sync output - **GraphQL cursor stall**: `hasNextPage=true` but `endCursor` is `None` or unchanged → pagination aborted with warning, partial result returned (prevents infinite loops from GraphQL cursor bugs) ## Decisions 1. **Store color + icon_name** — YES. Used for colored CLI output in human view. 2. **Run on every sync** — always enrich, not just `--full`. This is vital data. 3. **Include `--status` filter** — YES, in v1. `lore list issues --status "In progress"` 4. **Factory over raw token** — YES. `client.graphql_client()` keeps token encapsulated. 5. **Transactional enrichment** — YES. All-or-nothing per project prevents partial/stale state. 6. **Case-insensitive `--status` filter** — YES. Better UX, especially for custom status names. ASCII `COLLATE NOCASE` is sufficient — all system statuses are ASCII, and custom names are overwhelmingly ASCII too. 7. **Flat fields over nested JSON object** — YES. Consistent with existing `labels`, `milestone_title` pattern. Works with `--fields` selection. Nesting would break `--fields` syntax and require special dot-path resolution. 8. **No retry/backoff in v1** — DEFER. REST client doesn't have retry either. This is a cross-cutting concern that should be built once as a shared transport layer (`transport.rs`) for both REST and GraphQL, not bolted onto GraphQL alone. Adding retry only for GraphQL creates split behavior and maintenance burden. Note: adaptive page sizing (Decision 18) handles the specific case of complexity/timeout errors without needing general retry. 9. **No capability probe/cache in v1** — DEFER. Graceful degradation (empty map on 404/403) is sufficient. The warning is once per project per sync — acceptable noise. Cache table adds migration complexity and a new DB schema concept for marginal benefit. 10. **`status_synced_at` column** — YES. Lightweight enrichment freshness timestamp (ms epoch UTC). Enables consumers to detect stale data and supports future delta-driven enrichment. Written on both status-set and status-clear operations to distinguish "never enriched" (NULL) from "enriched but no status" (timestamp set, status NULL). 11. **Enrichment error in robot output** — YES. `status_enrichment_error: Option` on `IngestProjectResult` + per-project `status_enrichment` object in robot sync JSON. Agents need machine-readable signal when enrichment fails — silent warnings in logs are invisible to automation. 12. **No `status_name_fold` shadow column** — REJECT. `COLLATE NOCASE` handles ASCII case-folding which covers all system statuses. A fold column doubles write cost for negligible benefit. 13. **No `--status-category` / `--no-status` in v1** — DEFER. Keep v1 focused on core `--status` filter. These are easy additions once usage patterns emerge. 14. **No strict mode** — DEFER. A `status_enrichment_strict` config toggle adds config bloat for an edge case. The `status_enrichment_error` field gives agents the signal they need to implement their own strict behavior. 15. **Explicit outcome mode in robot output** — YES. `status_enrichment.mode` distinguishes `"fetched"` / `"unsupported"` / `"skipped"` with optional `reason` field. Resolves the ambiguity where agents couldn't tell "project has no statuses" from "feature unavailable" — both previously looked like an empty success. Cheap to implement since `FetchStatusResult` already has `unsupported_reason`. 16. **GraphQL partial-data tolerance with end-to-end metadata** — YES. When GraphQL returns both `data` and `errors`, use the data and propagate error metadata (`had_partial_errors`/`first_partial_error`) through `GraphqlQueryResult` → `FetchStatusResult` → `IngestProjectResult` → robot sync output. Agents get machine-readable signal that status data may be incomplete, rather than silent log-only warnings. 17. **Pagination guard against cursor stall** — YES. If `hasNextPage=true` but `endCursor` is `None` or unchanged, abort the loop and return partial results. This is a zero-cost safety valve against infinite loops from GraphQL cursor bugs. The alternative (trusting the server unconditionally) risks hanging the sync indefinitely. 18. **Adaptive page sizing** — YES. Start with `first=100`, fall back to 50→25→10 on GraphQL complexity/timeout errors. This is NOT general retry/backoff (Decision 8) — it specifically handles self-hosted GitLab instances with stricter complexity/time limits by reducing the page size that caused the problem. After a successful page, resets to 100 (the complexity issue may be cursor-position-specific). Zero operational cost — adds ~15 lines and 4 tests. 19. **Repeatable `--status` filter** — YES. `--status "To do" --status "In progress"` with OR semantics. Practical for "show me active work" queries. Clap supports `Vec` natively, and dynamic IN clause generation is straightforward. Single-value case uses `=` for simplicity. 20. **Non-fatal project path lookup** — YES. Uses `.optional()?` instead of `?` for the `path_with_namespace` DB query. If the project path is missing, enrichment is skipped with `status_enrichment_error: "project_path_missing"` and the sync continues to discussion phase. Enrichment is optional — it should never take down the entire project pipeline. 21. **Coverage telemetry counters** — YES. `seen`, `enriched`, `cleared`, `without_widget` in `IngestProjectResult` and robot sync output. `enriched`/`cleared` alone cannot distinguish "project has 0 issues" from "project has 500 issues with 0 statuses." Coverage counters cost one `len()` call and one subtraction — negligible. ## Future Enhancements (Not in Scope) These ideas surfaced during planning (iterations 3-6, cross-model reviews) but are out of scope for this implementation. File as separate beads if/when needed. ### Filters & Querying - **`--status-category` filter**: Filter by category (`in_progress`, `done`, etc.) for automation — more stable than name-based filtering for custom lifecycles. Consider adding a `COLLATE NOCASE` index on `(project_id, status_category)` when this lands. - **`--no-status` filter**: Return only issues where `status_name IS NULL` — useful for migration visibility and data quality audits. - **`--stale-status-days N` filter**: Filter issues where status hasn't changed in N days — requires `status_changed_at` column (see below). ### Enrichment Optimization - **Delta-driven enrichment**: Skip GraphQL fetch when issue ingestion reports zero changes for a project (optimization for large repos). Use `status_synced_at` (already in v1 schema) to determine last enrichment time. Add `status_full_reconcile_hours` config (default 24) to force periodic full sweep as safety net. - **`--refresh-status` override flag**: Force enrichment even with zero issue deltas. - **Capability probe/cache**: Detect status-widget support per project, cache with TTL in a `project_capabilities` table to avoid repeated pointless GraphQL calls on Free tier. Re-probe on TTL expiry (default 24h). The `status_synced_at` column provides a foundation — if a project's issues all have NULL `status_synced_at`, it's likely unsupported. ### Schema Extensions - **`status_changed_at` column**: Track when status *value* last changed (ms epoch UTC) — enables "how long has this been in progress?" queries, `--stale-status-days N` filter, and `status_age_days` field in robot output. Requires change-detection logic during enrichment (compare old vs new before UPDATE). Note: `status_synced_at` (in v1) records when enrichment last *ran* on the row, not when the status value changed — these serve different purposes. - **`status_id` column**: Store GitLab's global node ID (e.g., `gid://gitlab/WorkItems::Statuses::Status/1`) for rename-resistant identity. Useful if automations need to track a specific status across renames. Deferred because the global node ID is opaque, instance-specific, and not useful for cross-instance queries or human consumption — `status_name` is the primary user-facing identifier. ### Infrastructure - **Shared transport with retry/backoff**: Retry 429/502/503/504/network errors with exponential backoff + jitter (max 3 attempts) — should be a cross-cutting concern via shared `transport.rs` layer for both REST and GraphQL clients. Must respect cancellation signal between retry sleeps. - **Set-based bulk enrichment**: For very large projects (10k+ issues), replace row-by-row UPDATE loop with temp-table + set-based SQL for faster write-lock release. Profile first to determine if this is actually needed — SQLite `prepare_cached` statements may be sufficient. ### Operational - **Strict mode**: `status_enrichment_strict: bool` config toggle that makes enrichment failure fail the sync. Agents can already implement equivalent behavior by checking `status_enrichment_error` in the robot sync output (added in v1). - **`partial_failures` array in robot sync envelope**: Aggregate all enrichment errors into a top-level array for easy agent consumption. Currently per-project `status_enrichment.error` provides the data — this would be a convenience aggregation. - **Status/state consistency checks**: Detect status-state mismatches (e.g., `DONE` category with `state=open`, or `IN_PROGRESS` category with `state=closed`) and log warnings. Useful for data integrity diagnostics, but status-state sync is GitLab's responsibility and temporary mismatches during sync windows are expected — not appropriate for v1 enrichment logic. ### Cancellation - **Pass cancellation signal into fetcher**: Thread the existing `CancellationSignal` into `fetch_issue_statuses()` to check between page requests. Currently the orchestrator checks cancellation before enrichment starts, and individual page fetches complete in <1s, so the worst-case delay before honoring cancellation is ~1s per page (negligible). Worth adding if enrichment grows to include multiple fetch operations per project. --- ## Battle-Test Results (Iteration 2, pre-revision) > This plan was fully implemented in a trial run. All 435+ tests passed, clippy clean, fmt clean. > The code was then reverted so the plan could be reviewed before real implementation. > The corrections below are already reflected in the code snippets above. > > **Note (Iteration 3):** The plan was revised based on external review feedback. Key changes: > transactional enrichment with staleness clearing, `graphql_client()` factory pattern, > 403 handling, compound index, case-insensitive filter, 5 additional tests, and dropped > `WorkItemStatusCategory` enum in favor of `Option` (custom statuses on 18.5+ can > have arbitrary category values). The trial implementation predates these revisions — the > next trial should validate the new behavior. > > **Note (Iteration 4):** Revised based on cross-model review (ChatGPT). Key changes: > `__typename`-based deterministic widget matching (replaces heuristic try-deserialize), > `all_fetched_iids` changed from `Vec` to `HashSet` for O(1) staleness lookups, > `statuses_cleared` counter added for enrichment observability, `StatusEnrichmentSkipped` > progress event for config-off case, idempotency test added, expanded future enhancements > with deferred ideas (status_changed_at, capability cache, shared transport, set-based bulk). > Rejected scope-expanding suggestions: shared transport layer, capability cache table, > delta-driven enrichment, NOCASE index changes, `--status-category`/`--no-status` filters, > `status_changed_at` column — all deferred to future enhancements with improved descriptions. > > **Note (Iteration 5):** Revised based on second cross-model review (ChatGPT feedback-5). > **Accepted:** (1) `status_synced_at INTEGER` column — lightweight freshness timestamp for > enrichment observability and future delta-driven optimization, written on both set and clear > operations; (2) `status_enrichment_error: Option` on `IngestProjectResult` — captures > error message for machine-readable failure reporting; (3) AC-10 for robot sync envelope with > per-project `status_enrichment` object; (4) `Retry-After` HTTP-date format support via > `httpdate` crate; (5) 5 new tests (HTTP-date, clear synced_at, error capture, sync envelope). > **Rejected with rationale:** retry/backoff (cross-cutting, not GraphQL-only), capability cache > (migration complexity for marginal benefit), delta-driven enrichment (profile first), > set-based SQL (premature optimization), strict mode (agents can use error field), Unicode > case-folding column (ASCII COLLATE NOCASE sufficient), nested JSON status object (breaks > --fields syntax), `--status-category`/`--no-status` filters (keep v1 focused). > Expanded Decisions section from 9 to 14 with explicit rationale for each rejection. > Reorganized Future Enhancements into categorized subsections. > > **Note (Iteration 6):** Revised based on third cross-model review (ChatGPT feedback-6). > **Accepted:** (1) GraphQL partial-data tolerance — when response has both `data` and `errors`, > return data and log warning instead of hard-failing (per GraphQL spec, partial results are valid); > (2) Pagination guard — abort loop if `hasNextPage=true` but `endCursor` is `None` or unchanged > (prevents infinite loops from GraphQL cursor bugs); (3) `UnsupportedReason` enum + > `unsupported_reason` field on `FetchStatusResult` — distinguishes "no statuses found" from "feature > unavailable" for robot sync output clarity; (4) Enhanced AC-10 with `mode`/`reason` fields for > explicit enrichment outcome reporting. Added 3 new tests (partial-data, cursor stall, unsupported > reason on success). Updated Decisions 15-17. Added `status_id` to Future Enhancements. > **Rejected with rationale:** see Rejected Recommendations section below. > > **Note (Iteration 7):** Revised based on fourth cross-model review (ChatGPT feedback-7). > **Accepted:** (1) Partial-error metadata end-to-end — `GraphqlQueryResult` struct replaces raw > `Value` return, propagating `had_partial_errors`/`first_partial_error` through `FetchStatusResult` > → `IngestProjectResult` → robot sync output (agents get machine-readable incomplete-data signal); > (2) Adaptive page sizing — `first=100→50→25→10` fallback on complexity/timeout errors, handles > self-hosted instances with stricter limits without needing general retry/backoff; (3) Non-fatal > project path lookup — `.optional()?` prevents enrichment from crashing the project pipeline; > (4) Repeatable `--status` filter — `Vec` with OR semantics via IN clause; (5) Coverage > telemetry — `seen`, `without_widget`, `partial_error_count`, `first_partial_error` counters on > `IngestProjectResult` for full observability. Added 7 new tests (adaptive page size ×4, multi-status, > path missing, partial errors tracked). Updated Decisions 16, 18-21. > **Rejected with rationale:** centralized color parsing module (see Rejected Recommendations). ### Corrections Made During Trial | Issue | Root Cause | Fix Applied | |---|---|---| | `LoreError::GitLabRateLimited { retry_after }` uses `u64`, not `Option` | Plan assumed `Option` for retry_after | Added `.unwrap_or(60)` after parsing Retry-After header | | Raw string `r#"..."#` breaks on `"#1f75cb"` in test JSON | The `"#` sequence matches the raw string terminator | Use `r##"..."##` double-hash delimiters in test code | | Clippy: collapsible if statements (5 instances) | `if x { if y { ... } }` → `if x && y { ... }` | Used `if ... && let ...` chain syntax (Rust 2024 edition) | | Clippy: items after test module | `ansi256_from_rgb()` was placed after `#[cfg(test)]` | Moved function before the test module | | Clippy: manual range contains | `assert!(blue >= 16 && blue <= 231)` | Changed to `assert!((16..=231).contains(&blue))` | | Missing token accessor on `GitLabClient` | Orchestrator needs token to create `GraphqlClient` | Added `pub fn token(&self) -> &str` to `client.rs` | | Missing `path_with_namespace` in orchestrator | Only had `project_id`, needed project path for GraphQL | Added DB query to look up path from projects table | | Exhaustive match on `ProgressEvent` | Adding enum variant breaks `ingest.rs` match | Added `StatusEnrichmentComplete` arm to progress callback | | Legacy `List` command missing `status` field | Two `ListFilters` constructions in `main.rs` | Added `status: None` to legacy command's `ListFilters` | | Autocorrect registry test failure | All clap flags must be registered in `COMMAND_FLAGS` | Added `"--status"` to the `"issues"` entry in `autocorrect.rs` | ### Files Not in Original Plan (Discovered During Trial) - `migrations/021_work_item_status.sql` — separate file, not inline in db.rs (uses `include_str!`) - `src/gitlab/client.rs` — needs `graphql_client()` factory for GraphQL client creation (revised from `token()` accessor) - `src/cli/commands/ingest.rs` — progress callback match must be updated - `src/cli/autocorrect.rs` — flag registry test requires all new flags registered - `src/main.rs` — legacy `List` command path also constructs `ListFilters` ### Test Results from Trial (all green, pre-revision) - 435 lib tests + 136 integration/benchmark tests = 571 total - 11 new GraphQL tests (client, status fetcher, auth, pagination, graceful degradation) - 5 new orchestrator tests (migration, enrich, skip unknown IIDs, progress event, result default) - 3 new config tests (default true, deserialize false, omitted defaults true) - All existing tests remained green **Post-revision additions (5 new tests, iteration 3):** `test_graphql_403_maps_to_auth_failed`, `test_migration_021_adds_index`, `test_enrich_clears_removed_status`, `test_enrich_transaction_rolls_back_on_failure`, `test_list_filter_by_status_case_insensitive` **Post-revision additions (2 new tests, iteration 4):** `test_enrich_idempotent_across_two_runs`, `test_typename_matching_ignores_non_status_widgets` **Post-revision additions (5 new tests, iteration 5):** `test_retry_after_http_date_format`, `test_retry_after_invalid_falls_back_to_60`, `test_enrich_sets_synced_at_on_clear`, `test_enrichment_error_captured_in_result`, `test_robot_sync_includes_status_enrichment` **Post-revision additions (3 new tests, iteration 6):** `test_graphql_partial_data_with_errors_returns_data`, `test_fetch_statuses_cursor_stall_aborts`, `test_fetch_statuses_unsupported_reason_none_on_success` **Post-revision additions (7 new tests, iteration 7):** `test_fetch_statuses_complexity_error_reduces_page_size`, `test_fetch_statuses_timeout_error_reduces_page_size`, `test_fetch_statuses_smallest_page_still_fails`, `test_fetch_statuses_page_size_resets_after_success`, `test_list_filter_by_multiple_statuses`, `test_project_path_missing_skips_enrichment`, `test_fetch_statuses_partial_errors_tracked` --- ## Rejected Recommendations Cumulative log of recommendations considered and rejected across iterations. Prevents future reviewers from re-proposing the same changes. ### Iteration 4 (ChatGPT feedback-4) - **Shared transport layer with retry/backoff** — rejected because this is a cross-cutting concern that should be built once for both REST and GraphQL, not bolted onto GraphQL alone. Adding retry only for GraphQL creates split behavior. Deferred to Future Enhancements. - **Capability cache table** — rejected because migration complexity and new DB schema concept for marginal benefit. Graceful degradation (empty map on 404/403) is sufficient. - **Delta-driven enrichment** — rejected because needs profiling first to determine if full re-fetch is actually a bottleneck. Premature optimization. - **NOCASE index changes** — rejected because `COLLATE NOCASE` on the comparison is sufficient; adding it to the index definition provides no benefit for SQLite's query planner in this case. - **`--status-category` / `--no-status` filters in v1** — rejected because keeping v1 focused. Easy additions once usage patterns emerge. - **`status_changed_at` column in v1** — rejected because requires change-detection logic during enrichment (compare old vs new) which adds complexity. `status_synced_at` serves different purpose and is sufficient for v1. ### Iteration 5 (ChatGPT feedback-5) - **Retry/backoff (again)** — rejected for same reason as iteration 4. Cross-cutting concern. - **Capability cache (again)** — rejected for same reason as iteration 4. - **Delta-driven enrichment (again)** — rejected for same reason as iteration 4. - **Set-based bulk SQL** — rejected because premature optimization. SQLite `prepare_cached` statements may be sufficient. Profile first. - **Strict mode config toggle** — rejected because adds config bloat for edge case. Agents can implement equivalent behavior by checking `status_enrichment_error`. - **Unicode case-folding shadow column (`status_name_fold`)** — rejected because `COLLATE NOCASE` handles ASCII case-folding which covers all system statuses. Doubles write cost for negligible benefit. - **Nested JSON status object** — rejected because breaks `--fields` syntax and requires special dot-path resolution. Flat fields are consistent with existing patterns. - **`--status-category` / `--no-status` filters (again)** — rejected for same reason as iteration 4. ### Iteration 6 (ChatGPT feedback-6) - **`FetchStatusOutcome` enum with `CancelledPartial` variant** — rejected because over-engineered for v1. The simpler approach of adding `unsupported_reason: Option` to `FetchStatusResult` provides the same observability signal (distinguishing "no statuses" from "feature unavailable") without introducing a 3-variant enum that forces match arms everywhere. The partial-from-cancellation case is not needed since the orchestrator checks cancellation before starting enrichment and individual page fetches complete in <1s. - **Pass cancellation signal into `fetch_issue_statuses()`** — rejected because the orchestrator already checks cancellation before enrichment starts, and individual page fetches are <1s. Threading the signal through adds a parameter and complexity for negligible benefit. Deferred to Future Enhancements (Cancellation section) in case enrichment grows to include multiple fetch operations. - **Persist `status_id` column (GitLab global node ID)** — rejected because GitLab's GraphQL `id` is a global node ID (e.g., `gid://gitlab/WorkItems::Statuses::Status/1`) that is opaque, instance-specific, and not useful for cross-instance queries or human consumption — the `name` is what users see and filter by. Added to Future Enhancements (Schema Extensions) as a deferred option for rename-resistant identity if needed. - **Extract status enrichment into `src/ingestion/enrichment/status.rs` module** — rejected because the enrichment logic is ~60 lines (one orchestrator block + one helper function). Creating a new module, directory, and `mod.rs` for this is premature abstraction. The orchestrator is the right home until enrichment grows to justify extraction. - **Status/state consistency checks (mismatch detection)** — rejected because status-state sync is GitLab's responsibility and temporary mismatches during sync windows are expected behavior. Adding mismatch detection to enrichment adds complexity for a diagnostic signal that would generate false positives. Deferred to Future Enhancements (Operational). - **Performance envelope acceptance criterion (AC-12)** — rejected because "10k-issue fixture on CI baseline machine" is unmeasurable without CI infrastructure and test fixtures we don't have. Pagination handles large projects by design (100/page). Testing memory bounds requires test harness complexity far beyond the value for v1. ### Iteration 7 (ChatGPT feedback-7) - **Centralize color parsing into `src/cli/commands/color.rs` module** — rejected because the two color helpers (`style_with_hex` in show.rs, `colored_cell_hex` in list.rs) return different types (`console::StyledObject` vs `comfy_table::Cell`) for different rendering contexts. The shared hex-parsing logic is 4 lines. Creating a new module + file for this is premature abstraction per project rules ("only abstract when you have 3+ similar uses"). If a third color context emerges, extract then.