Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [0.9.48] - 2026-09-01

### Added
- **Queue/TaskStore integration for webhook-triggered reviews**: MR reviews triggered by GitLab webhooks are now visible in the live queue and history. Each webhook creates a task entry that tracks `pending` → `running` → `completed`/`failed`, broadcasts SSE events, and surfaces on the Queue Monitor and Recent Reviews dashboard. (`src/server/state.rs`, `src/server/gitlab/hooks.rs`, `src/server/task_queue.rs`, `src/server/api/dashboard.rs`, `src/server/api/queue.rs`)
- **System hook verification fallback for single platforms**: when a GitLab System Hook payload carries no URL, or its URL does not match any configured platform, verification succeeds if exactly one platform has webhook credentials. This handles the GitLab "Test" button and payloads from instances with a single reachable endpoint. (`src/server/gitlab/handler.rs`)

### Fixed
- **Queue Monitor no longer appears blank when only completed tasks exist**: completed tasks are now shown under a "Recently completed" section, and the empty-state check counts all tasks instead of only active ones. (`frontend/src/views/QueueMonitor.vue`)

## [0.9.47] - 2026-09-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "review-engine"
version = "0.9.47"
version = "0.9.48"
license = "Apache-2.0"
edition = "2021"

Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export default {
queued: 'Queued Tasks',
failed: 'Failed Tasks',
cancelled: 'Cancelled Tasks',
completed: 'Recently Completed Tasks',
},
empty: 'No tasks in queue',
emptyHint: 'The queue is currently empty. New tasks will appear here.',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export default {
queued: 'Tâches en file',
failed: 'Tâches en échec',
cancelled: 'Tâches annulées',
completed: 'Tâches récemment terminées',
},
empty: 'Aucune tâche dans la file',
emptyHint: 'La file est actuellement vide. Les nouvelles tâches apparaîtront ici.',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export default {
queued: '待機タスク',
failed: '失敗タスク',
cancelled: 'キャンセル済みタスク',
completed: '最近完了タスク',
},
empty: 'キューにタスクがありません',
emptyHint: 'キューは現在空です。新しいタスクはここに表示されます。',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export default {
queued: '대기 중 태스크',
failed: '실패한 태스크',
cancelled: '취소된 태스크',
completed: '최근 완료된 태스크',
},
empty: '대기열에 태스크가 없습니다',
emptyHint: '대기열이 비어 있습니다. 새 태스크가 여기에 표시됩니다.',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export default {
queued: '排队任务',
failed: '失败任务',
cancelled: '已取消任务',
completed: '最近完成任务',
},
empty: '队列中没有任务',
emptyHint: '队列当前为空。新任务将显示在这里。',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export default {
queued: '排隊任務',
failed: '失敗任務',
cancelled: '已取消任務',
completed: '最近完成任務',
},
empty: '佇列中沒有任務',
emptyHint: '佇列目前為空。新任務會顯示在這裡。',
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/views/QueueMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ const activeTasks = computed(() => queue.items.value.filter((t: QueueTask) => t.
const queuedTasks = computed(() => queue.items.value.filter((t: QueueTask) => t.status === 'queued'))
const failedTasks = computed(() => queue.items.value.filter((t: QueueTask) => t.status === 'failed'))
const cancelledTasks = computed(() => queue.items.value.filter((t: QueueTask) => t.status === 'cancelled'))
const allTasks = computed(() => queue.items.value)
const completedTasks = computed(() => queue.items.value.filter((t: QueueTask) => t.status === 'completed'))
// The backend returns every status (including `completed`) when no status
// filter is passed. Completed tasks are rendered in their own section below,
// so the "no tasks" placeholder must cover the full list — otherwise a
// completed-only result suppresses the placeholder while the four active
// sections stay empty, leaving a blank page.
const hasAnyTasks = computed(() => queue.items.value.length > 0)

// --- Load queue data ---
const loadQueueData = async () => {
Expand Down Expand Up @@ -461,8 +467,33 @@ onUnmounted(() => {
</TransitionGroup>
</div>

<!-- Recently Completed Tasks -->
<div
v-if="completedTasks.length > 0"
class="task-section"
>
<div class="section-header">
<div class="section-title">
<span>{{ $t('queue.sections.completed') }}</span>
<el-badge :value="completedTasks.length" type="success" />
</div>
</div>
<TransitionGroup name="task" tag="div" class="task-grid">
<TaskCard
v-for="task in completedTasks"
:key="task.id"
:task="task"
:is-paused="isPaused"
:was-updated="recentlyUpdated.includes(task.id)"
@cancel="handleCancel"
@retry="handleRetry"
@view-logs="handleViewLogs"
/>
</TransitionGroup>
</div>

<!-- Global Empty State -->
<div v-if="allTasks.length === 0" class="global-empty">
<div v-if="!hasAnyTasks" class="global-empty">
<el-empty :description="$t('queue.empty')">
<template #image>
<el-icon :size="64" color="var(--text-secondary)"><InfoFilled /></el-icon>
Expand Down
1 change: 0 additions & 1 deletion src/cli/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,6 @@ pub async fn run() -> Result<()> {
.or_else(|| std::env::var("GITLAB_WEBHOOK_SIGNING_SECRET").ok())
.filter(|s| !s.is_empty());
let mut app_state = review_engine::server::AppState::new(config.llm.clone());
app_state.task_store = Some(Arc::new(review_engine::server::task_queue::TaskStore::new()));
app_state.app_config = std::sync::RwLock::new(Some(Arc::new(config.clone())));
app_state.registry = Some(review_engine::metrics::REGISTRY.clone());
app_state.progress_map = Some(progress_map.clone());
Expand Down
39 changes: 39 additions & 0 deletions src/server/api/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,43 @@ mod tests {
assert_eq!(by_id["00000000-0000-0000-0000-000000000004"], "failed");
assert_eq!(by_id["00000000-0000-0000-0000-000000000005"], "cancelled");
}

/// `None` fallback: without a store the dashboard serves documented
/// defaults (zero KPIs, empty recent reviews, offline health) instead of
/// 503 — a pure guard, unreachable in production where the store is
/// always present.
#[tokio::test]
async fn dashboard_without_store_serves_defaults() {
let mut state = AppState::new(vec![]);
state.task_store = None;
let resp = get_dashboard(State(std::sync::Arc::new(state))).await.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["kpis"]["reviewsThisWeek"], 0);
assert_eq!(json["kpis"]["activeQueue"], 0);
assert!(json["recentReviews"].as_array().unwrap().is_empty());
assert_eq!(json["health"]["overall"], "offline");
}

/// With a store the dashboard reflects the real task store — completed
/// reviews from the webhook path surface as recentReviews (the exact
/// defect this fix closes).
#[tokio::test]
async fn dashboard_with_store_shows_webhook_recorded_review() {
let state = AppState::new(vec![]);
let store = state.task_store.clone().unwrap();
let id = store.create(None).await;
store.update(id, TaskState::Completed, None, None).await;

let resp = get_dashboard(State(std::sync::Arc::new(state))).await.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["kpis"]["reviewsThisWeek"], 1);
let recent = json["recentReviews"].as_array().unwrap();
assert_eq!(recent.len(), 1);
assert_eq!(recent[0]["id"], id.to_string());
assert_eq!(recent[0]["status"], "completed");
}
}
107 changes: 107 additions & 0 deletions src/server/api/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,110 @@ fn task_to_queue_task(entry: &TaskEntry) -> serde_json::Value {
"errorMessage": entry.error,
})
}

#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;

/// AppState with the store deliberately cleared — the only way the
/// `None` fallback paths are reachable now that `AppState::new`
/// initialises a store eagerly.
fn state_without_store() -> Arc<AppState> {
let mut state = AppState::new(vec![]);
state.task_store = None;
Arc::new(state)
}

fn state_with_store() -> Arc<AppState> {
Arc::new(AppState::new(vec![]))
}

async fn body_of(response: impl IntoResponse) -> serde_json::Value {
let resp = response.into_response();
let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}

/// `None` fallback: the stats endpoint keeps serving the documented
/// defaults instead of 503 — a pure guard, unreachable in production
/// where the store is always present.
#[tokio::test]
async fn stats_without_store_falls_back_to_defaults() {
let json = body_of(get_queue_stats(State(state_without_store())).await).await;
assert_eq!(json["active"], 0);
assert_eq!(json["queued"], 0);
assert_eq!(json["failed"], 0);
assert_eq!(json["totalDepth"], 0);
assert_eq!(json["maxConcurrent"], 8);
assert_eq!(json["queueCapacity"], 16);
assert_eq!(json["failedLast24h"], 0);
assert_eq!(json["totalLast24h"], 0);
assert_eq!(json["isPaused"], false);
}

/// With a store the stats reflect the real task store — the fix that makes
/// the queue page show actual activity instead of hardcoded zeros.
#[tokio::test]
async fn stats_with_store_reflects_real_entries() {
let state = state_with_store();
let store = state.task_store.clone().unwrap();
let id = store.create(None).await;
store.update(id, TaskState::Running, None, None).await;
let failed = store.create(None).await;
store
.update(failed, TaskState::Failed, None, Some("boom".to_string()))
.await;

let json = body_of(get_queue_stats(State(state)).await).await;
assert_eq!(json["active"], 1);
assert_eq!(json["queued"], 0);
assert_eq!(json["failed"], 1);
assert_eq!(json["totalDepth"], 1);
assert_eq!(json["maxConcurrent"], 8);
assert_eq!(json["queueCapacity"], 16);
assert_eq!(json["failedLast24h"], 1);
assert_eq!(json["totalLast24h"], 2);
}

#[tokio::test]
async fn tasks_without_store_returns_503() {
let resp = get_queue_tasks(
State(state_without_store()),
Query(QueueTaskParams {
status: None,
page: None,
per_page: None,
}),
)
.await
.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let json = body_of(resp).await;
assert_eq!(json["error"], "task store not initialized");
}

#[tokio::test]
async fn tasks_with_store_returns_real_entries() {
let state = state_with_store();
let store = state.task_store.clone().unwrap();
let id = store.create(None).await;
store.update(id, TaskState::Completed, None, None).await;

let json = body_of(
get_queue_tasks(
State(state),
Query(QueueTaskParams {
status: None,
page: None,
per_page: None,
}),
)
.await,
)
.await;
assert_eq!(json["total"], 1);
assert_eq!(json["items"][0]["id"], id.to_string());
assert_eq!(json["items"][0]["status"], "completed");
}
}
45 changes: 45 additions & 0 deletions src/server/api/review/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,51 @@ async fn test_list_reviews_merges_camelcase_and_keeps_snakecase() {
assert!(item.get("rawComment").is_none(), "list items must not carry rawComment");
}

/// The exact webhook defect: a webhook-dispatched review records a task entry
/// (via `record_task_started` / `record_task_outcome` — the same helpers the
/// GitLab hook path uses) that then surfaces in `/api/v1/reviews`. Before the
/// TaskStore wiring, webhook reviews never created an entry, so this list was
/// empty no matter how many MRs were reviewed.
#[tokio::test]
async fn webhook_recorded_task_surfaces_in_list_reviews() {
let state = state_with_store();
let store = state.task_store.clone().unwrap();

// Enqueue-time knowledge is only the MR URL + commit SHA (the dispatch
// dedup key); title/branch/author are absent for webhook tasks.
let mr_url = "http://gitlab.internal:8929/group/proj/-/merge_requests/7";
let sha = "abc123";
let id = crate::server::gitlab::record_task_started(&store, mr_url, sha).await;
let outcome: anyhow::Result<()> = Ok(());
crate::server::gitlab::record_task_outcome(&store, id, mr_url, sha, &outcome).await;

let params = ListParams {
status: None,
page: None,
per_page: None,
q: None,
project: None,
repository: None,
date_from: None,
date_to: None,
};
let resp = list_reviews(State(state), Query(params)).await.into_response();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();

assert_eq!(json["total"], 1, "the webhook-recorded task must be listed");
let item = &json["items"][0];
assert_eq!(item["task_id"], id.to_string());
assert_eq!(item["status"], "completed");
assert_eq!(item["gitlab_mr_url"], mr_url);
assert_eq!(item["commit_sha"], sha);
assert_eq!(
item["gitlabMrUrl"], mr_url,
"camelCase key must mirror the snake_case one"
);
}

#[tokio::test]
async fn test_absent_metadata_is_null_in_both_naming_schemes() {
let state = state_with_store();
Expand Down
Loading
Loading