Three implementation plans with iterative cross-model refinement: lore-service (5 iterations): HTTP service layer exposing lore's SQLite data via REST/SSE for integration with external tools (dashboards, IDE extensions, chat agents). Covers authentication, rate limiting, caching strategy, and webhook-driven sync triggers. work-item-status-graphql (7 iterations + TDD appendix): Detailed implementation plan for the GraphQL-based work item status enrichment feature (now implemented). Includes the TDD appendix with test-first development specifications covering GraphQL client, adaptive pagination, ingestion orchestration, CLI display, and robot mode output. time-decay-expert-scoring (iteration 5 feedback): Updates to the existing time-decay scoring plan incorporating feedback on decay curve parameterization, recency weighting for discussion contributions, and staleness detection thresholds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
88 KiB
plan, title, status, iteration, target_iterations, beads_revision, related_plans, created, updated
| plan | title | status | iteration | target_iterations | beads_revision | related_plans | created | updated |
|---|---|---|---|---|---|---|---|---|
| true | Work Item Status via GraphQL Enrichment | iterating | 7 | 8 | 1 | 2026-02-10 | 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/graphqlwithContent-Type: application/json- Request uses
Authorization: Bearer {token}header (NOTPRIVATE-TOKEN) - Request body is
{"query": "...", "variables": {...}} - Successful response: parses
datafield from JSON envelope - Error response: if top-level
errorsarray is non-empty ANDdatafield is absent/null, returnsLoreErrorwith first error message - Partial-data response: if
errorsarray is non-empty BUTdatafield is present and non-null, returnsGraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some("...") }(does NOT fail the query — GraphQL spec permitsdata+errorscoexistence for partial results) GraphqlQueryResultstruct:data: serde_json::Value,had_partial_errors: bool,first_partial_error: Option<String>— 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(respectsRetry-Afterheader — supports both delta-seconds and HTTP-date formats, falls back to 60s if unparseable) - Network error →
LoreError::Other
AC-2: Status Types (Unit)
WorkItemStatusstruct hasname: String,category: Option<String>,color: Option<String>,icon_name: Option<String>categorystored as raw string from GraphQL (e.g.,"IN_PROGRESS","TO_DO") — no enum, no normalizationWorkItemStatusdeserializes from GraphQL JSON shape withname,category,color,iconName- All fields except
nameareOption— absent fields deserialize toNone - Custom statuses (18.5+) with non-standard category values deserialize without error
AC-3: Status Fetcher (Integration)
fetch_issue_statuses()returnsFetchStatusResultcontaining:statuses: HashMap<i64, WorkItemStatus>keyed by issue IID (parsed from GraphQL's String to i64)all_fetched_iids: HashSet<i64>— all IIDs seen in GraphQL response (for staleness clearing)unsupported_reason: Option<UnsupportedReason>— 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<String>— first partial-error message for diagnostic surfacing
- Paginates: follows
pageInfo.endCursor+hasNextPageuntil all pages consumed - Pagination guard: if
hasNextPage=truebutendCursorisNoneor 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 hardcodedfirst: 100) to support adaptive page sizing - Query includes
__typenameinwidgetsselection; parser matches__typename == "WorkItemWidgetStatus"for deterministic widget identification (no heuristic try-deserialize) - Non-status widgets are ignored deterministically via
__typenamecheck - Issues with no status widget in
widgetsarray → inall_fetched_iidsbut not instatusesmap (no error) - Issues with status widget but
status: null→ inall_fetched_iidsbut not instatusesmap - GraphQL 404 → returns
Ok(FetchStatusResult)with empty collections,unsupported_reason == Some(UnsupportedReason::GraphqlEndpointMissing), + warning log - GraphQL 403 (
GitLabAuthFailed) → returnsOk(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--statusfilter performance LATEST_SCHEMA_VERSIONbecomes 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 issuessucceeds - NULL default: existing rows have NULL for all 5 new columns
AC-5: Config Toggle (Unit)
SyncConfighasfetch_work_item_status: boolfield- Default value:
true(uses existingdefault_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— iffalse, skipped entirely - Creates
GraphqlClientviaclient.graphql_client()factory (token stays encapsulated) - For each project: calls
fetch_issue_statuses(), then UPDATEs matchingissuesrows - 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_atto current epoch ms (records when we confirmed absence of status) - Logs summary:
"Enriched {n} issues with work item status for {project}"— includesseen,enriched,cleared,without_widgetcounts - 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 IngestProjectResultgainsstatuses_enriched: usize,statuses_cleared: usize,statuses_seen: usize,statuses_without_widget: usize,partial_error_count: usize,first_partial_error: Option<String>, andstatus_enrichment_error: Option<String>(captures error message when enrichment fails for this project)- Progress events:
StatusEnrichmentComplete { enriched, cleared }andStatusEnrichmentSkipped(when config toggle is false) - Enrichment log line includes
seen,enriched,cleared, andwithout_widgetcounts for full observability
AC-7: Show Issue Display (E2E)
Human (lore show issue 123):
- New line after "State":
Status: In progress(colored bystatus_colorhex → 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 show issue 123):
- JSON includes
status_name,status_category,status_color,status_icon_name,status_synced_atfields - Fields are
null(not absent) when status not available status_synced_atis integer (ms epoch UTC) ornull— 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_colorhex → nearest terminal color - NULL status → empty cell (no placeholder text)
Robot (lore --robot list issues):
- JSON includes
status_name,status_categoryfields on each issue --fieldssupports:status_name,status_category,status_color,status_icon_name,status_synced_at--fields minimalpreset does NOT include status fields (keeps token count low)
AC-9: List Issues Filter (E2E)
lore list issues --status "In progress"→ only issues wherestatus_name = 'In progress'- Filter uses case-insensitive matching (
COLLATE NOCASE) for UX —"in progress"matches"In progress" --statusis repeatable:--status "In progress" --status "To do"→ issues matching ANY of the given statuses (OR semantics within--status, AND with other filters)- Single
--statusproducesWHERE status_name = ? COLLATE NOCASE; multiple produceWHERE status_name IN (?, ?) COLLATE NOCASE --statuscombined with other filters (e.g.,--state opened --status "To do") → AND logic--statuswith no matching issues → "No issues found."
AC-10: Robot Sync Envelope (E2E)
lore --robot syncJSON response includes per-projectstatus_enrichmentobject:{ "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;reasonexplains why; all counters are 0mode: "skipped"— config toggle is off; all other fields are 0/null- When enrichment fails for a project:
errorcontains the error message string, counters are 0,modeis"fetched"(it attempted) partial_errors> 0 indicates GraphQL returned partial-data responses (data + errors) — agents can use this to flag potentially incomplete status dataseencounter enables agents to distinguish "project has 0 issues" from "project has 500 issues but 0 with status"- Aggregate
status_enrichment_errors: Nin top-level sync summary for quick agent health checks
AC-11: Compiler & Quality Gates
cargo check --all-targetspasses with zero errorscargo clippy --all-targets -- -D warningspasses (pedantic + nursery enabled)cargo fmt --checkpassescargo testpasses — 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 <token>
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.
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<String>) 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.
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<String>,
}
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<GraphqlQueryResult> {
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::<u64>().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<u64>
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<ProjectNode>,
}
#[derive(Deserialize)]
struct ProjectNode {
#[serde(rename = "workItems")]
work_items: Option<WorkItemConnection>,
}
#[derive(Deserialize)]
struct WorkItemConnection {
nodes: Vec<WorkItemNode>,
#[serde(rename = "pageInfo")]
page_info: PageInfo,
}
#[derive(Deserialize)]
struct WorkItemNode {
iid: String, // GraphQL returns iid as String
widgets: Vec<serde_json::Value>,
}
#[derive(Deserialize)]
struct PageInfo {
#[serde(rename = "endCursor")]
end_cursor: Option<String>,
#[serde(rename = "hasNextPage")]
has_next_page: bool,
}
#[derive(Deserialize)]
struct StatusWidget {
status: Option<WorkItemStatus>,
}
/// 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<i64, WorkItemStatus>,
pub all_fetched_iids: HashSet<i64>,
pub unsupported_reason: Option<UnsupportedReason>,
pub partial_error_count: usize,
pub first_partial_error: Option<String>,
}
/// 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<i64>` (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<FetchStatusResult> {
let mut statuses = HashMap::new();
let mut all_fetched_iids = HashSet::new();
let mut cursor: Option<String> = None;
let mut page_size_idx = 0; // index into PAGE_SIZES
let mut partial_error_count = 0usize;
let mut first_partial_error: Option<String> = 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::<i64>() {
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::<StatusWidget>(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):
pub mod client;
pub mod transformers;
pub mod types;
Add after pub mod types;:
pub mod graphql;
Add to pub use types::{...} list:
WorkItemStatus,
File 3: src/gitlab/types.rs (MODIFY)
Add at end of file (after GitLabMergeRequest struct, before any #[cfg(test)]):
/// 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<String>,
pub color: Option<String>,
#[serde(rename = "iconName")]
pub icon_name: Option<String>,
}
File 4: src/core/db.rs (MODIFY)
Existing pattern — each migration is a tuple of (&str, &str) loaded via include_str!:
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:
("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:
#[serde(rename = "fetchWorkItemStatus", default = "default_true")]
pub fetch_work_item_status: bool,
In impl Default for SyncConfig, add after fetch_mr_file_changes: true:
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:
// ── 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<String> = 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:
/// 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<i64, crate::gitlab::types::WorkItemStatus>,
all_fetched_iids: &HashSet<i64>,
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:
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<String>, // first partial-error message
pub status_enrichment_error: Option<String>,
pub status_enrichment_mode: String, // "fetched" | "unsupported" | "skipped"
pub status_unsupported_reason: Option<String>, // "graphql_endpoint_missing" | "auth_forbidden"
Modify ProgressEvent enum — add variants:
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:
status_name: Option<String>,
status_category: Option<String>,
status_color: Option<String>,
status_icon_name: Option<String>,
status_synced_at: Option<i64>,
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.
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)?:
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:
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
Modify run_show_issue return — add fields from issue row:
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):
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:
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:
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
Modify From<&IssueDetail> for IssueDetailJson — add after existing field mappings:
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:
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
Modify IssueListRowJson — add 5 fields (all for robot mode):
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
Modify From<&IssueListRow> for IssueListRowJson — add after existing field mappings:
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:
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:
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:
pub statuses: &'a [String],
Modify query_issues WHERE clause builder — add after has_due_date block:
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
Cell::new("Status").add_attribute(Attribute::Bold),
Modify print_list_issues row — add status cell after state cell (before assignee cell):
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:
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):
/// Filter by work item status name (e.g., "In progress"). Repeatable for OR semantics.
#[arg(long, help_heading = "Filters")]
pub status: Vec<String>,
File 10: src/main.rs (MODIFY)
In handle_issues function (~line 695), add statuses to ListFilters construction:
let filters = ListFilters {
// ... existing fields ...
statuses: &args.status,
};
In legacy List command handler (~line 2421), also add statuses: &[] to ListFilters:
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:
(
"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:
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):
/// 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)
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<String>) |
+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<String> 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
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 })test_graphql_query_with_errors_no_data— mock returns{"errors":[{"message":"bad"}]}(nodatafield) →Err(LoreError::Other("GraphQL error: bad"))test_graphql_auth_uses_bearer— mock asserts request hasAuthorization: Bearer tok123headertest_graphql_401_maps_to_auth_failed— mock returns 401 →Err(LoreError::GitLabAuthFailed)test_graphql_403_maps_to_auth_failed— mock returns 403 →Err(LoreError::GitLabAuthFailed)test_graphql_404_maps_to_not_found— mock returns 404 →Err(LoreError::GitLabNotFound)test_work_item_status_deserialize— parse{"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}→ category isSome("IN_PROGRESS")test_work_item_status_optional_fields— parse{"name":"To do"}→ category/color/icon_name are Nonetest_work_item_status_unknown_category— parse{"name":"Custom","category":"SOME_FUTURE_VALUE"}→ category isSome("SOME_FUTURE_VALUE")(no deserialization error)test_work_item_status_null_category— parse{"name":"In progress","category":null}→ category is Nonetest_fetch_statuses_pagination— mock returns 2 pages → all statuses in map, all_fetched_iids includes all IIDstest_fetch_statuses_no_status_widget— mock returns widgets without StatusWidget → empty statuses map, all_fetched_iids still populatedtest_fetch_statuses_404_graceful— mock returns 404 →Ok(FetchStatusResult)with empty maps,unsupported_reason == Some(GraphqlEndpointMissing)test_fetch_statuses_403_graceful— mock returns 403 →Ok(FetchStatusResult)with empty maps,unsupported_reason == Some(AuthForbidden)test_migration_021_adds_columns— in-memory DB:PRAGMA table_info(issues)includes 5 new columns (includingstatus_synced_at)test_migration_021_adds_index— in-memory DB:PRAGMA index_list(issues)includesidx_issues_project_status_nametest_enrich_issue_statuses_txn— insert issue, callenrich_issue_statuses_txn(), verify 4 status columns populated +status_synced_atis set tonow_mstest_enrich_skips_unknown_iids— status map has IID not in DB → no error, returns 0test_enrich_clears_removed_status— issue previously had status, now inall_fetched_iidsbut not instatuses→ status fields NULLed out,status_synced_atupdated tonow_ms(not NULLed — confirms we checked this row)test_enrich_transaction_rolls_back_on_failure— simulate failure mid-enrichment → no partial updates, prior status values intacttest_list_filter_by_status— insert 2 issues with different statuses, filter returns correct onetest_list_filter_by_status_case_insensitive—--status "in progress"matches"In progress"via COLLATE NOCASEtest_config_fetch_work_item_status_default_true—SyncConfig::default().fetch_work_item_status == truetest_config_deserialize_without_key— JSON withoutfetchWorkItemStatus→ defaults totruetest_ansi256_from_rgb— known conversions:(0,0,0)→16,(255,255,255)→231,(31,117,203)→~68test_enrich_idempotent_across_two_runs— runenrich_issue_statuses_txn()twice with same data → columns unchanged,enrichedcount same both timestest_typename_matching_ignores_non_status_widgets— widgets array with__typename: "WorkItemWidgetDescription"→ not parsed as status, no errortest_retry_after_http_date_format— mock returns 429 withRetry-After: Wed, 11 Feb 2026 01:00:00 GMT→ parses to delta seconds from nowtest_retry_after_invalid_falls_back_to_60— mock returns 429 withRetry-After: garbage→ falls back to 60test_enrich_sets_synced_at_on_clear— issue with status cleared →status_synced_atisnow_ms(not NULL)test_enrichment_error_captured_in_result— simulate GraphQL error →IngestProjectResult.status_enrichment_errorcontains error message stringtest_robot_sync_includes_status_enrichment— robot sync output JSON includes per-projectstatus_enrichmentobject withmode,reason,seen,enriched,cleared,without_widget,partial_errors,first_partial_error,errorfieldstest_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") })test_fetch_statuses_cursor_stall_aborts— mock returnshasNextPage: truewith sameendCursoron consecutive pages → pagination aborts with warning, returns partial result collected so fartest_fetch_statuses_unsupported_reason_none_on_success— successful fetch →unsupported_reasonisNonetest_fetch_statuses_complexity_error_reduces_page_size— mock returns complexity error on first page withfirst=100→ retries withfirst=50, succeeds → result contains all statuses from the successful pagetest_fetch_statuses_timeout_error_reduces_page_size— mock returns timeout error on first page withfirst=100→ retries with smaller page size, succeedstest_fetch_statuses_smallest_page_still_fails— mock returns complexity error at all page sizes (100→50→25→10) → returns Errtest_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)test_list_filter_by_multiple_statuses—--status "In progress" --status "To do"→ returns issues matching either statustest_project_path_missing_skips_enrichment— project with nopath_with_namespacein DB → enrichment skipped,status_enrichment_error == "project_path_missing", sync continuestest_fetch_statuses_partial_errors_tracked— mock returns partial-data response on one page →partial_error_count == 1,first_partial_errorpopulated
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
WorkItemStatusstruct totypes.rs(no enum — category isOption<String>) - Register migration 021 in
db.rs - Add
fetch_work_item_statustoconfig.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.rswith full client (GraphqlQueryResultstruct, partial-error metadata, HTTP-date Retry-After) + fetcher +FetchStatusResult(withpartial_error_count/first_partial_error) +UnsupportedReason+ adaptive page sizing + pagination guard +ansi256_from_rgb - Add
pub mod graphql;togitlab/mod.rs - Add
pub fn graphql_client()factory toclient.rs - Add
httpdatecrate toCargo.tomldependencies (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()(withnow_msparam) to orchestrator - Add coverage telemetry fields (
statuses_seen,statuses_without_widget,partial_error_count,first_partial_error) toIngestProjectResult - Add
status_enrichment_error: Option<String>,status_enrichment_mode: String,status_unsupported_reason: Option<String>toIngestProjectResult - Add
StatusEnrichmentComplete+StatusEnrichmentSkippedtoProgressEventenum - Add match arms in
ingest.rsprogress callback - Run:
cargo test orchestrator
Batch 4: CLI Display + Filter (Files 7-11 → Tests 21-22, 40)
- Add status fields (including
status_synced_at) toshow.rsstructs, SQL, display - Add status fields to
list.rsstructs, SQL, column, repeatable--statusfilter (with IN clause + COLLATE NOCASE) - Add
--statusflag tocli/mod.rsasVec<String>(repeatable) - Add
"--status"to autocorrect registry - Wire
statusesin bothListFiltersconstructions inmain.rs - Wire
status_enrichment(withmode/reason/seen/without_widget/partial_errors/first_partial_errorfields) into robot sync output envelope - Run:
cargo test(full suite)
Batch 5: Quality Gates (AC-11)
cargo check --all-targetscargo clippy --all-targets -- -D warningscargo fmt --checkcargo 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;FetchStatusResultis notClone— tests must check fields individually;__typenametest mock data must include the field in widget JSON objects;httpdatecrate needed for Retry-After HTTP-date parsing — add toCargo.toml; pagination guard test needs mock that returns sameendCursortwice; partial-data test needs mock that returns bothdataanderrorsfields; adaptive page size tests need mock that inspects$firstvariable in request body to return different responses per page size;GraphqlQueryResult(not rawValue) is now the return type — test assertions must destructure it - Batch 3: Progress callback in
ingest.rsmust be updated in same batch as enum change (2 new arms:StatusEnrichmentComplete+StatusEnrichmentSkipped);unchecked_transaction()needed becauseconnis&Connectionnot&mut Connection;enrich_issue_statuses_txntakes 5 params now (addednow_ms: i64) and returns(usize, usize)tuple — destructure at call site;status_enrichment_errormust be populated on theErrbranch;status_enrichment_modeandstatus_unsupported_reasonmust be set in all code paths; project path lookup uses.optional()?— requiresuse rusqlite::OptionalExtension;import - Batch 4: Autocorrect registry must be updated in same batch as clap flag addition;
COLLATE NOCASEapplies to the comparison, not the column definition;status_synced_atisOption<i64>in Rust structs (maps to nullable INTEGER in SQLite);--statusisVec<String>notOption<String>—ListFilters.statusesis&[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
categoryfield 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_apiscope: 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-Afterheader 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
firstparameter 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_errorand 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
httpdatecrate. Invalid format falls back to 60s. - NULL hex color:
style_with_hexandcolored_cell_hexfall back to unstyled text - Invalid hex color: Malformed color string → fall back to unstyled text
- Empty project:
fetch_issue_statusesreturns 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
dataanderrors→ data is used,partial_error_countincremented,first_partial_errorcaptured — agents can detect incomplete data via robot sync output - GraphQL cursor stall:
hasNextPage=truebutendCursorisNoneor unchanged → pagination aborted with warning, partial result returned (prevents infinite loops from GraphQL cursor bugs)
Decisions
- Store color + icon_name — YES. Used for colored CLI output in human view.
- Run on every sync — always enrich, not just
--full. This is vital data. - Include
--statusfilter — YES, in v1.lore list issues --status "In progress" - Factory over raw token — YES.
client.graphql_client()keeps token encapsulated. - Transactional enrichment — YES. All-or-nothing per project prevents partial/stale state.
- Case-insensitive
--statusfilter — YES. Better UX, especially for custom status names. ASCIICOLLATE NOCASEis sufficient — all system statuses are ASCII, and custom names are overwhelmingly ASCII too. - Flat fields over nested JSON object — YES. Consistent with existing
labels,milestone_titlepattern. Works with--fieldsselection. Nesting would break--fieldssyntax and require special dot-path resolution. - 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. - 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.
status_synced_atcolumn — 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).- Enrichment error in robot output — YES.
status_enrichment_error: Option<String>onIngestProjectResult+ per-projectstatus_enrichmentobject in robot sync JSON. Agents need machine-readable signal when enrichment fails — silent warnings in logs are invisible to automation. - No
status_name_foldshadow column — REJECT.COLLATE NOCASEhandles ASCII case-folding which covers all system statuses. A fold column doubles write cost for negligible benefit. - No
--status-category/--no-statusin v1 — DEFER. Keep v1 focused on core--statusfilter. These are easy additions once usage patterns emerge. - No strict mode — DEFER. A
status_enrichment_strictconfig toggle adds config bloat for an edge case. Thestatus_enrichment_errorfield gives agents the signal they need to implement their own strict behavior. - Explicit outcome mode in robot output — YES.
status_enrichment.modedistinguishes"fetched"/"unsupported"/"skipped"with optionalreasonfield. 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 sinceFetchStatusResultalready hasunsupported_reason. - GraphQL partial-data tolerance with end-to-end metadata — YES. When GraphQL returns both
dataanderrors, use the data and propagate error metadata (had_partial_errors/first_partial_error) throughGraphqlQueryResult→FetchStatusResult→IngestProjectResult→ robot sync output. Agents get machine-readable signal that status data may be incomplete, rather than silent log-only warnings. - Pagination guard against cursor stall — YES. If
hasNextPage=truebutendCursorisNoneor 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. - 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. - Repeatable
--statusfilter — YES.--status "To do" --status "In progress"with OR semantics. Practical for "show me active work" queries. Clap supportsVec<String>natively, and dynamic IN clause generation is straightforward. Single-value case uses=for simplicity. - Non-fatal project path lookup — YES. Uses
.optional()?instead of?for thepath_with_namespaceDB query. If the project path is missing, enrichment is skipped withstatus_enrichment_error: "project_path_missing"and the sync continues to discussion phase. Enrichment is optional — it should never take down the entire project pipeline. - Coverage telemetry counters — YES.
seen,enriched,cleared,without_widgetinIngestProjectResultand robot sync output.enriched/clearedalone cannot distinguish "project has 0 issues" from "project has 500 issues with 0 statuses." Coverage counters cost onelen()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-categoryfilter: Filter by category (in_progress,done, etc.) for automation — more stable than name-based filtering for custom lifecycles. Consider adding aCOLLATE NOCASEindex on(project_id, status_category)when this lands.--no-statusfilter: Return only issues wherestatus_name IS NULL— useful for migration visibility and data quality audits.--stale-status-days Nfilter: Filter issues where status hasn't changed in N days — requiresstatus_changed_atcolumn (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. Addstatus_full_reconcile_hoursconfig (default 24) to force periodic full sweep as safety net. --refresh-statusoverride flag: Force enrichment even with zero issue deltas.- Capability probe/cache: Detect status-widget support per project, cache with TTL in a
project_capabilitiestable to avoid repeated pointless GraphQL calls on Free tier. Re-probe on TTL expiry (default 24h). Thestatus_synced_atcolumn provides a foundation — if a project's issues all have NULLstatus_synced_at, it's likely unsupported.
Schema Extensions
status_changed_atcolumn: Track when status value last changed (ms epoch UTC) — enables "how long has this been in progress?" queries,--stale-status-days Nfilter, andstatus_age_daysfield 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_idcolumn: 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_nameis 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.rslayer 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_cachedstatements may be sufficient.
Operational
- Strict mode:
status_enrichment_strict: boolconfig toggle that makes enrichment failure fail the sync. Agents can already implement equivalent behavior by checkingstatus_enrichment_errorin the robot sync output (added in v1). partial_failuresarray in robot sync envelope: Aggregate all enrichment errors into a top-level array for easy agent consumption. Currently per-projectstatus_enrichment.errorprovides the data — this would be a convenience aggregation.- Status/state consistency checks: Detect status-state mismatches (e.g.,
DONEcategory withstate=open, orIN_PROGRESScategory withstate=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
CancellationSignalintofetch_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 droppedWorkItemStatusCategoryenum in favor ofOption<String>(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_iidschanged fromVec<i64>toHashSet<i64>for O(1) staleness lookups,statuses_clearedcounter added for enrichment observability,StatusEnrichmentSkippedprogress 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-statusfilters,status_changed_atcolumn — 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 INTEGERcolumn — lightweight freshness timestamp for enrichment observability and future delta-driven optimization, written on both set and clear operations; (2)status_enrichment_error: Option<String>onIngestProjectResult— captures error message for machine-readable failure reporting; (3) AC-10 for robot sync envelope with per-projectstatus_enrichmentobject; (4)Retry-AfterHTTP-date format support viahttpdatecrate; (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-statusfilters (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
dataanderrors, return data and log warning instead of hard-failing (per GraphQL spec, partial results are valid); (2) Pagination guard — abort loop ifhasNextPage=truebutendCursorisNoneor unchanged (prevents infinite loops from GraphQL cursor bugs); (3)UnsupportedReasonenum +unsupported_reasonfield onFetchStatusResult— distinguishes "no statuses found" from "feature unavailable" for robot sync output clarity; (4) Enhanced AC-10 withmode/reasonfields for explicit enrichment outcome reporting. Added 3 new tests (partial-data, cursor stall, unsupported reason on success). Updated Decisions 15-17. Addedstatus_idto 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 —
GraphqlQueryResultstruct replaces rawValuereturn, propagatinghad_partial_errors/first_partial_errorthroughFetchStatusResult→IngestProjectResult→ robot sync output (agents get machine-readable incomplete-data signal); (2) Adaptive page sizing —first=100→50→25→10fallback 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--statusfilter —Vec<String>with OR semantics via IN clause; (5) Coverage telemetry —seen,without_widget,partial_error_count,first_partial_errorcounters onIngestProjectResultfor 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<u64> |
Plan assumed Option<u64> 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 (usesinclude_str!)src/gitlab/client.rs— needsgraphql_client()factory for GraphQL client creation (revised fromtoken()accessor)src/cli/commands/ingest.rs— progress callback match must be updatedsrc/cli/autocorrect.rs— flag registry test requires all new flags registeredsrc/main.rs— legacyListcommand path also constructsListFilters
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 NOCASEon the comparison is sufficient; adding it to the index definition provides no benefit for SQLite's query planner in this case. --status-category/--no-statusfilters in v1 — rejected because keeping v1 focused. Easy additions once usage patterns emerge.status_changed_atcolumn in v1 — rejected because requires change-detection logic during enrichment (compare old vs new) which adds complexity.status_synced_atserves 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_cachedstatements 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 becauseCOLLATE NOCASEhandles ASCII case-folding which covers all system statuses. Doubles write cost for negligible benefit. - Nested JSON status object — rejected because breaks
--fieldssyntax and requires special dot-path resolution. Flat fields are consistent with existing patterns. --status-category/--no-statusfilters (again) — rejected for same reason as iteration 4.
Iteration 6 (ChatGPT feedback-6)
FetchStatusOutcomeenum withCancelledPartialvariant — rejected because over-engineered for v1. The simpler approach of addingunsupported_reason: Option<UnsupportedReason>toFetchStatusResultprovides 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_idcolumn (GitLab global node ID) — rejected because GitLab's GraphQLidis 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 — thenameis 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.rsmodule — rejected because the enrichment logic is ~60 lines (one orchestrator block + one helper function). Creating a new module, directory, andmod.rsfor 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.rsmodule — rejected because the two color helpers (style_with_hexin show.rs,colored_cell_hexin list.rs) return different types (console::StyledObjectvscomfy_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.