Define the ForgeService struct and error types
Intent: Create a standalone service layer that owns the dependencies needed for forge operations (store, config, live updates, forge runtime) and defines domain-specific error and result types that are independent of HTTP concerns.
Affected files: crates/pika-news/src/forge_service.rs
Evidence
@@ -0,0 +1,522 @@
+pub(crate) struct ForgeService {
+ store: Store,
+ config: Config,
+ live_updates: CiLiveUpdates,
+ forge_runtime: Arc<ForgeRuntime>,
+}
@@ -0,0 +1,522 @@
+pub(crate) enum ForgeServiceError {
+ NotFound(String),
+ Conflict(String),
+ Internal(String),
+}
@@ -0,0 +1,522 @@
+pub(crate) struct MergeBranchResult {
+ pub(crate) branch_id: i64,
+ pub(crate) merge_commit_sha: String,
+}
@@ -0,0 +1,522 @@
+pub(crate) struct BranchDetailAndRuns {
+ pub(crate) detail: BranchDetailRecord,
+ pub(crate) ci_runs: Vec<BranchCiRunRecord>,
+}
The new forge_service.rs file introduces ForgeService, a Clone-able struct that bundles all dependencies required by forge operations: Store, Config, CiLiveUpdates, and Arc<ForgeRuntime>. A constructor (ForgeService::new) accepts these four parameters.
Alongside the service struct, a ForgeServiceError enum with three variants (NotFound, Conflict, Internal) replaces the ad-hoc (StatusCode, String) tuples that were previously scattered across web handlers. Each forge operation returns a dedicated result struct (MergeBranchResult, CloseBranchResult, BranchLaneRerunResult, NightlyLaneRerunResult, BranchLaneMutationResult, NightlyLaneMutationResult, BranchRunRecoveryResult, NightlyRunRecoveryResult, BranchDetailAndRuns) that carries only the domain-relevant fields. This makes the API boundary explicit and testable without any HTTP framework dependency.
Implement query methods: branch detail, branch resolution, and nightly runs
Intent: Move read-path logic (fetching branch details with CI runs, resolving branches by name, fetching nightly runs) out of web handlers and into ForgeService async methods that handle spawn_blocking internally.
Affected files: crates/pika-news/src/forge_service.rs
Evidence
@@ -0,0 +1,522 @@
+ pub(crate) async fn branch_detail_and_runs(
+ &self,
+ branch_id: i64,
+ run_limit: usize,
+ ) -> Result<Option<BranchDetailAndRuns>, ForgeServiceError> {
@@ -0,0 +1,522 @@
+ pub(crate) async fn resolve_branch_by_name(
+ &self,
+ branch_name: &str,
+ ) -> Result<Option<BranchLookupRecord>, ForgeServiceError> {
@@ -0,0 +1,522 @@
+ pub(crate) async fn nightly_run(
+ &self,
+ nightly_run_id: i64,
+ ) -> Result<Option<NightlyRunRecord>, ForgeServiceError> {
branch_detail_and_runs replaces the old load_branch_detail_and_runs free function that lived in web.rs. It clones the store, spawns two spawn_blocking tasks (one for get_branch_detail, one for list_branch_ci_runs), and wraps all error cases into ForgeServiceError. The method returns Option<BranchDetailAndRuns> so callers can distinguish "not found" from errors.
resolve_branch_by_name encapsulates the config lookup for the effective forge repo and the store query find_branch_by_name, removing this logic from the API handler.
nightly_run is a thin wrapper around store.get_nightly_run with spawn_blocking, replacing identical inline code in both the HTML page handler and the API detail handler.
Implement mutation methods: merge, close, lane reruns, fail/requeue, and recovery
Intent: Consolidate all forge write operations—merging branches, closing branches, rerunning CI lanes, failing/requeuing lanes, and recovering runs—into ForgeService methods that handle validation, forge API calls, store updates, live-update notifications, and runtime wake signals.
Affected files: crates/pika-news/src/forge_service.rs
Evidence
@@ -0,0 +1,522 @@
+ pub(crate) async fn merge_branch(
+ &self,
+ branch_id: i64,
+ merged_by: &str,
+ ) -> Result<MergeBranchResult, ForgeServiceError> {
@@ -0,0 +1,522 @@
+ pub(crate) async fn close_branch(
+ &self,
+ branch_id: i64,
+ closed_by: &str,
+ ) -> Result<CloseBranchResult, ForgeServiceError> {
@@ -0,0 +1,522 @@
+ pub(crate) async fn rerun_branch_ci_lane(
+ &self,
+ branch_id: i64,
+ lane_run_id: i64,
+ ) -> Result<Option<BranchLaneRerunResult>, ForgeServiceError> {
@@ -0,0 +1,522 @@
+ pub(crate) async fn recover_branch_ci_run(
+ &self,
+ branch_id: i64,
+ run_id: i64,
+ ) -> Result<Option<BranchRunRecoveryResult>, ForgeServiceError> {
@@ -0,0 +1,522 @@
+ async fn branch_action_target(
+ &self,
+ branch_id: i64,
+ ) -> Result<BranchActionTarget, ForgeServiceError> {
The merge_branch method consolidates a complex multi-step workflow that was previously inline in merge_handler: config validation, loading the branch action target, verifying the current head SHA against the forge, calling forge::merge_branch, marking the branch merged in the store, and requesting a mirror. Each step maps errors to the appropriate ForgeServiceError variant (Internal for infrastructure failures, Conflict for state mismatches, NotFound for missing branches).
close_branch follows the same pattern for branch deletion. Both methods share a private helper branch_action_target that loads and validates the branch exists.
The lane mutation methods (rerun_branch_ci_lane, rerun_nightly_lane, fail_branch_ci_lane, requeue_branch_ci_lane, fail_nightly_lane, requeue_nightly_lane) each follow a consistent pattern: spawn a blocking store call, on success emit a live-update notification and wake the CI scheduler, then return a typed result. The nightly equivalents mirror the branch methods.
Recovery methods (recover_branch_ci_run, recover_nightly_run) handle bulk lane recovery with the same pattern. A wake_ci pass-through method is also exposed for the simple wake endpoint.
Register the module and wire ForgeService into AppState
Intent: Make the new module visible to the crate and instantiate ForgeService during server startup so it is available to all web handlers via shared application state.
Affected files: crates/pika-news/src/main.rs, crates/pika-news/src/web.rs
Evidence
@@ -7,6 +7,7 @@ mod cli;
mod config;
mod forge;
mod forge_runtime;
+mod forge_service;
@@ -51,6 +56,7 @@ struct AppState {
live_updates: CiLiveUpdates,
webhook_secret: Option<String>,
forge_runtime: Arc<ForgeRuntime>,
+ forge_service: Arc<ForgeService>,
}
@@ -641,6 +660,12 @@ pub async fn serve(
+ let forge_service = Arc::new(ForgeService::new(
+ store.clone(),
+ config.clone(),
+ live_updates.clone(),
+ Arc::clone(&forge_runtime),
+ ));
@@ -674,6 +699,7 @@ pub async fn serve(
+ forge_service,
In main.rs, the mod forge_service; declaration is added in alphabetical order among the existing module declarations.
In web.rs, the AppState struct gains a forge_service: Arc<ForgeService> field. During serve() initialization, ForgeService::new is constructed with clones of the store, config, live_updates, and a shared reference to the forge runtime. The resulting Arc<ForgeService> is stored in AppState and becomes available to every handler via State(state).
Add error mapping utilities for HTTP responses
Intent: Provide reusable helper functions that convert ForgeServiceError into HTTP status codes and JSON error responses, avoiding repetitive match arms in every handler.
Affected files: crates/pika-news/src/web.rs
Evidence
@@ -529,6 +535,19 @@ fn push_page_notice(
+fn map_forge_service_error(err: ForgeServiceError) -> (StatusCode, String) {
+ match err {
+ ForgeServiceError::NotFound(message) => (StatusCode::NOT_FOUND, message),
+ ForgeServiceError::Conflict(message) => (StatusCode::CONFLICT, message),
+ ForgeServiceError::Internal(message) => (StatusCode::INTERNAL_SERVER_ERROR, message),
+ }
+}
+
+fn forge_service_json_error(err: ForgeServiceError) -> axum::response::Response {
+ let (status, message) = map_forge_service_error(err);
+ (status, Json(serde_json::json!({ "error": message }))).into_response();
+}
Two helper functions are introduced in web.rs:
-
map_forge_service_error converts a ForgeServiceError into a (StatusCode, String) tuple, mapping NotFound to 404, Conflict to 409, and Internal to 500. This is used by HTML-rendering handlers that return (StatusCode, String) pairs.
-
forge_service_json_error wraps the above to produce a full axum::response::Response with a JSON {"error": "..."} body. This is used by all JSON API handlers.
These two functions replace dozens of inline error-to-response conversions that were previously duplicated across handlers.
Rewrite HTML page handlers to use ForgeService
Intent: Replace inline spawn_blocking store calls and manual error handling in the nightly page handler, branch CI page handler, detail page, and live-update snapshot loaders with concise ForgeService method calls.
Affected files: crates/pika-news/src/web.rs
Evidence
@@ -904,32 +930,20 @@ async fn nightly_handler(
- let store = state.store.clone();
- let nightly =
- match tokio::task::spawn_blocking(move || store.get_nightly_run(nightly_run_id)).await {
+ let nightly = match state.forge_service.nightly_run(nightly_run_id).await {
@@ -953,18 +967,24 @@ async fn branch_ci_page_handler(
- let (detail, ci_runs) =
- match load_branch_detail_and_runs(Arc::clone(&state), branch_id, 8).await {
+ let BranchDetailAndRuns { detail, ci_runs } = match state
+ .forge_service
+ .branch_detail_and_runs(branch_id, 8)
+ .await
@@ -1130,26 +1107,15 @@ async fn load_branch_ci_summary_snapshot(
- let detail_store = state.store.clone();
- let runs_store = state.store.clone();
+ let BranchDetailAndRuns { detail, ci_runs } = match state
+ .forge_service
+ .branch_detail_and_runs(branch_id, 8)
+ .await
The nightly_handler previously contained a full spawn_blocking + triple-match error handling block (Ok/Ok/Some, Ok/Ok/None, Ok/Err, Err). This is replaced with a single state.forge_service.nightly_run(nightly_run_id).await call and a three-arm match.
branch_ci_page_handler and detail_page both called the now-deleted load_branch_detail_and_runs free function. They now call state.forge_service.branch_detail_and_runs(branch_id, 8) and destructure the BranchDetailAndRuns result directly.
The load_branch_detail_and_runs function (approximately 50 lines) is completely removed from web.rs since its logic now lives in ForgeService::branch_detail_and_runs.
Similarly, load_branch_ci_summary_snapshot, load_branch_ci_live_snapshot, and load_nightly_live_snapshot all switch from inline spawn_blocking calls to the corresponding ForgeService methods, each saving 10-20 lines of boilerplate.
Rewrite JSON API mutation handlers to delegate to ForgeService
Intent: Replace the large inline implementations of merge, close, rerun, fail, requeue, and recovery handlers with thin wrappers that authenticate, call the appropriate ForgeService method, and format the response.
Affected files: crates/pika-news/src/web.rs
Evidence
@@ -2255,127 +2207,17 @@ async fn merge_handler(
- let Some(forge_repo) = state.config.effective_forge_repo() else {
+ match state.forge_service.merge_branch(branch_id, &npub).await {
+ Ok(MergeBranchResult {
+ branch_id,
+ merge_commit_sha,
+ }) => Json(serde_json::json!({
@@ -2388,122 +2230,14 @@ async fn close_handler(
+ match state.forge_service.close_branch(branch_id, &npub).await {
+ Ok(CloseBranchResult { branch_id, deleted }) => Json(serde_json::json!({
@@ -2515,35 +2249,26 @@ async fn rerun_branch_ci_lane_handler(
+ match state
+ .forge_service
+ .rerun_branch_ci_lane(branch_id, lane_run_id)
+ .await
@@ -2856,7 +2508,7 @@ async fn wake_ci_handler(
- state.forge_runtime.wake_ci();
+ state.forge_service.wake_ci();
The merge_handler shrinks from ~120 lines to ~15. Previously it inlined config checks, store queries via spawn_blocking, forge HEAD verification, the merge API call, store updates, and mirror requests—each with its own multi-arm error match. Now it calls state.forge_service.merge_branch(branch_id, &npub), destructures the typed MergeBranchResult, and calls forge_service_json_error on failure.
close_handler follows the same pattern, dropping from ~110 lines to ~12.
Each of the lane-level handlers (rerun_branch_ci_lane_handler, rerun_nightly_lane_handler, fail_branch_ci_lane_handler, requeue_branch_ci_lane_handler, fail_nightly_lane_handler, requeue_nightly_lane_handler) and the recovery handlers (recover_branch_ci_run_handler, recover_nightly_run_handler) are similarly reduced. The handlers no longer call state.live_updates or state.forge_runtime.wake_ci() directly—those side effects are encapsulated within the ForgeService methods.
The wake_ci_handler switches from state.forge_runtime.wake_ci() to state.forge_service.wake_ci(), maintaining the abstraction boundary.
Rewrite JSON API read handlers to use ForgeService
Intent: Replace inline store access in the branch resolve, branch detail, branch logs, and nightly detail API handlers with ForgeService method calls and the shared error mapping utilities.
Affected files: crates/pika-news/src/web.rs
Evidence
@@ -2880,36 +2532,24 @@ async fn api_forge_branch_resolve_handler(
- let repo = state
- .config
- .effective_forge_repo()
+ match state
+ .forge_service
+ .resolve_branch_by_name(&branch_name)
+ .await
@@ -2921,8 +2561,12 @@ async fn api_forge_branch_detail_handler(
- match load_branch_detail_and_runs(Arc::clone(&state), branch_id, 8).await {
- Ok(Some((detail, ci_runs))) => Json(ForgeBranchDetailResponse {
+ match state
+ .forge_service
+ .branch_detail_and_runs(branch_id, 8)
+ .await
+ {
+ Ok(Some(BranchDetailAndRuns { detail, ci_runs })) => Json(ForgeBranchDetailResponse {
@@ -3114,9 +2758,8 @@ async fn api_forge_nightly_detail_handler(
- let store = state.store.clone();
- match tokio::task::spawn_blocking(move || store.get_nightly_run(nightly_run_id)).await {
- Ok(Ok(Some(run))) => Json(ForgeNightlyDetailResponse {
+ match state.forge_service.nightly_run(nightly_run_id).await {
+ Ok(Some(run)) => Json(ForgeNightlyDetailResponse {
api_forge_branch_resolve_handler previously inlined the config lookup for the forge repo name and a spawn_blocking store query. It now calls state.forge_service.resolve_branch_by_name(&branch_name) and uses forge_service_json_error for the error path, cutting the handler by ~15 lines.
api_forge_branch_detail_handler and api_forge_branch_logs_handler both switch from the now-deleted load_branch_detail_and_runs to state.forge_service.branch_detail_and_runs, destructuring BranchDetailAndRuns. Error handling changes from Err((status, message)) to Err(err) => forge_service_json_error(err).
api_forge_nightly_detail_handler replaces its inline spawn_blocking + double-Result unwrap with state.forge_service.nightly_run(nightly_run_id).await, collapsing the nested Ok(Ok(Some(...))) pattern into a flat Ok(Some(...)).