Back to feed

sledtools/pika branch #63

pika-git-service-1

Extract forge service from web

Target branch: master

Merge Commit: 13bb4a145adf106dc7e6ddde9294b480a8a1e8d9

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #80 success

2 passed

head f9ddc1b844ae1ee7ab3efbdbbf3fc50711204ff3 · queued 2026-03-25 20:40:12 · 2 lane(s)

queued 7s · ran 28s

check-notifications · success check-agent-contracts · success

Summary

This branch extracts forge-related business logic from the web layer (web.rs) into a dedicated ForgeService struct in a new forge_service.rs module. The service encapsulates store access, config lookups, live-update notifications, and forge runtime interactions behind a clean async API with typed result/error enums. All web handlers that previously contained inline spawn_blocking calls, manual error mapping, and direct store/forge interactions are rewritten to delegate to ForgeService methods, dramatically reducing duplication and coupling in the HTTP layer. The web module retains only request parsing, authentication, template rendering, and response serialization.

Tutorial Steps

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(...)).

Diff