From ec0281e2642ad8dcc56109a49ff40165f2a158e4 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 16 Feb 2026 15:59:49 +0700 Subject: [PATCH] feat: show CI output in Forgejo UI via PR comments and status descriptions - Add target_url to StatusUpdate for linking commit statuses to PR comments - StatusReporter can now find open PRs, post markdown comments with job output, and re-post pipeline status with target_url pointing to the comment - On job failure, commit status description includes last ~10 output lines - Pipeline completion triggers a PR comment with
per job showing up to 100 lines of output - Add ureq json feature for Forgejo API response parsing Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- crates/ci/Cargo.toml | 2 +- crates/ci/src/coordinator.rs | 4 + crates/ci/src/lib.rs | 1 + crates/ci/src/local_coordinator.rs | 84 ++++++++- crates/ci/src/status_reporter.rs | 254 +++++++++++++++++++++++++- crates/simulation/src/ci/local_sim.rs | 3 + crates/simulation/src/ci/sim.rs | 2 + 7 files changed, 346 insertions(+), 4 deletions(-) diff --git a/crates/ci/Cargo.toml b/crates/ci/Cargo.toml index dff9d32..5a28b8e 100644 --- a/crates/ci/Cargo.toml +++ b/crates/ci/Cargo.toml @@ -11,7 +11,7 @@ serde_json = "1" # Local runner library dependencies tiny_http = { version = "0.12", optional = true } -ureq = { version = "2", optional = true } +ureq = { version = "2", features = ["json"], optional = true } hmac = { version = "0.12", optional = true } sha2 = { version = "0.10", optional = true } hex = { version = "0.4", optional = true } diff --git a/crates/ci/src/coordinator.rs b/crates/ci/src/coordinator.rs index 1b63fd1..1e94aac 100644 --- a/crates/ci/src/coordinator.rs +++ b/crates/ci/src/coordinator.rs @@ -120,6 +120,7 @@ impl Coordinator { state: "pending".into(), context: format!("ci/{pipeline_name}"), description: format!("Pipeline '{pipeline_name}' is pending"), + target_url: None, }); self.pipelines.insert(pipeline_id, pipeline); @@ -148,6 +149,7 @@ impl Coordinator { pipeline.pipeline_name, pipeline.status.forgejo_state() ), + target_url: None, }; self.emit_status_update(update); return; @@ -209,6 +211,7 @@ impl Coordinator { state: "pending".into(), context: format!("ci/{job_name}"), description: format!("Job '{job_name}' is provisioning"), + target_url: None, }); } } @@ -332,6 +335,7 @@ impl Coordinator { }, context: format!("ci/{job_name}"), description: format!("Job '{job_name}' completed"), + target_url: None, }); if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) { diff --git a/crates/ci/src/lib.rs b/crates/ci/src/lib.rs index f8c8601..5a0f2ad 100644 --- a/crates/ci/src/lib.rs +++ b/crates/ci/src/lib.rs @@ -268,6 +268,7 @@ pub struct StatusUpdate { pub state: String, pub context: String, pub description: String, + pub target_url: Option, } // ─── Coordinator Config ───────────────────────────────────────────────────── diff --git a/crates/ci/src/local_coordinator.rs b/crates/ci/src/local_coordinator.rs index 5fc6b48..2858662 100644 --- a/crates/ci/src/local_coordinator.rs +++ b/crates/ci/src/local_coordinator.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use crate::pipeline::PipelineExecution; -use crate::status_reporter::StatusReporterMsg; +use crate::status_reporter::{JobOutput, StatusReporterMsg}; use crate::yaml::{self, CiYaml}; use crate::{ JobComplete, JobId, JobProgress, JobStatus, LocalCiConfig, LocalStartJob, PipelineId, @@ -147,6 +147,7 @@ impl LocalCoordinator { state: "pending".into(), context: format!("ci/{pipeline_name}"), description: format!("Pipeline '{pipeline_name}' is pending"), + target_url: None, }, ); @@ -226,8 +227,10 @@ impl LocalCoordinator { pipeline.pipeline_name, pipeline.status.forgejo_state() ), + target_url: None, }, ); + self.emit_pipeline_comment(ctx, &pipeline); self.archive_pipeline(pipeline); self.active_pipeline = None; // Recurse to pick next from queue. @@ -307,6 +310,7 @@ impl LocalCoordinator { state: "pending".into(), context: format!("ci/{job_name}"), description: format!("Job '{job_name}' is running"), + target_url: None, }, ); } @@ -383,6 +387,41 @@ impl LocalCoordinator { }, }; + // Build a description with output tail for failures. + let description = match &status { + JobStatus::Passed => format!("Job '{job_name}' passed"), + JobStatus::Failed { reason } => { + let output_tail = self + .pipelines + .get(&pipeline_id) + .and_then(|p| p.jobs.get(&job_name)) + .map(|j| { + let lines: Vec<&str> = j + .output_lines + .iter() + .rev() + .take(10) + .map(|s| s.as_str()) + .collect(); + lines.into_iter().rev().collect::>().join("\n") + }) + .unwrap_or_default(); + + let mut desc = format!("Job '{job_name}' failed: {reason}"); + if !output_tail.is_empty() { + desc.push_str("\n"); + desc.push_str(&output_tail); + } + // Cap at ~250 chars for the status description field. + if desc.len() > 250 { + desc.truncate(247); + desc.push_str("..."); + } + desc + } + _ => format!("Job '{job_name}' completed"), + }; + // Emit per-job final status. if let Some(pipeline) = self.pipelines.get(&pipeline_id) { self.emit_status( @@ -396,7 +435,8 @@ impl LocalCoordinator { _ => "failure".into(), }, context: format!("ci/{job_name}"), - description: format!("Job '{job_name}' completed"), + description, + target_url: None, }, ); } @@ -425,6 +465,46 @@ impl LocalCoordinator { } } + fn emit_pipeline_comment(&self, ctx: &Ctx, pipeline: &crate::pipeline::PipelineExecution) { + let reporter_addr = match self.status_reporter_addr { + Some(addr) => addr, + None => return, + }; + + let job_outputs: Vec = pipeline + .jobs + .values() + .map(|job| { + let passed = job.status == JobStatus::Passed; + let failure_reason = match &job.status { + JobStatus::Failed { reason } => Some(reason.clone()), + _ => None, + }; + JobOutput { + job_name: job.definition.name.clone(), + passed, + failure_reason, + output_lines: job.output_lines.clone(), + } + }) + .collect(); + + let _ = ctx.send( + reporter_addr, + StatusReporterMsg::PostPipelineComment { + repo_owner: pipeline.repo_owner.clone(), + repo_name: pipeline.repo_name.clone(), + commit_sha: pipeline.commit_sha.clone(), + branch: pipeline.branch.clone(), + pipeline_name: pipeline.pipeline_name.clone(), + pipeline_state: pipeline.status.forgejo_state().to_string(), + job_outputs, + forgejo_url: self.config.ci.forgejo_url.clone(), + forgejo_token: self.config.ci.forgejo_token.clone(), + }, + ); + } + fn archive_pipeline(&mut self, pipeline: PipelineExecution) { self.completed.push_back(pipeline); if self.completed.len() > 50 { diff --git a/crates/ci/src/status_reporter.rs b/crates/ci/src/status_reporter.rs index b7b3a01..35f08e6 100644 --- a/crates/ci/src/status_reporter.rs +++ b/crates/ci/src/status_reporter.rs @@ -1,11 +1,21 @@ //! StatusReporter actor: fire-and-forget Forgejo commit status updates. //! //! Receives status update messages and POSTs them to the Forgejo API. +//! Can also post pipeline summary comments to PRs. use swactor::actor::{ActorInterface, Ctx}; use crate::StatusUpdate; +/// Captured output for a single job, used to build PR comments. +#[derive(Debug, Clone)] +pub struct JobOutput { + pub job_name: String, + pub passed: bool, + pub failure_reason: Option, + pub output_lines: Vec, +} + /// Messages the StatusReporter can receive. #[derive(Debug, Clone)] pub enum StatusReporterMsg { @@ -14,6 +24,17 @@ pub enum StatusReporterMsg { forgejo_url: String, forgejo_token: String, }, + PostPipelineComment { + repo_owner: String, + repo_name: String, + commit_sha: String, + branch: String, + pipeline_name: String, + pipeline_state: String, + job_outputs: Vec, + forgejo_url: String, + forgejo_token: String, + }, } /// StatusReporter actor state. @@ -34,12 +55,16 @@ impl StatusReporter { update.commit_sha, ); - let body = serde_json::json!({ + let mut body = serde_json::json!({ "state": update.state, "context": update.context, "description": update.description, }); + if let Some(ref target_url) = update.target_url { + body["target_url"] = serde_json::Value::String(target_url.clone()); + } + let result = ureq::post(&url) .set("Authorization", &format!("token {forgejo_token}")) .set("Content-Type", "application/json") @@ -49,6 +74,194 @@ impl StatusReporter { eprintln!("StatusReporter: failed to post status to {url}: {e}"); } } + + #[cfg(feature = "local")] + fn find_pr_for_branch( + forgejo_url: &str, + forgejo_token: &str, + repo_owner: &str, + repo_name: &str, + branch: &str, + ) -> Option { + let url = format!( + "{}/api/v1/repos/{}/{}/pulls?state=open&limit=50", + forgejo_url.trim_end_matches('/'), + repo_owner, + repo_name, + ); + + let response = ureq::get(&url) + .set("Authorization", &format!("token {forgejo_token}")) + .call(); + + let response = match response { + Ok(r) => r, + Err(e) => { + eprintln!("StatusReporter: failed to list PRs: {e}"); + return None; + } + }; + + let body: serde_json::Value = match response.into_json() { + Ok(v) => v, + Err(e) => { + eprintln!("StatusReporter: failed to parse PR list: {e}"); + return None; + } + }; + + let prs = body.as_array()?; + for pr in prs { + let head_ref = pr.get("head")?.get("ref")?.as_str()?; + if head_ref == branch { + return pr.get("number")?.as_u64(); + } + } + None + } + + #[cfg(feature = "local")] + fn post_pr_comment( + forgejo_url: &str, + forgejo_token: &str, + repo_owner: &str, + repo_name: &str, + pr_number: u64, + body_text: &str, + ) -> Option { + let url = format!( + "{}/api/v1/repos/{}/{}/issues/{}/comments", + forgejo_url.trim_end_matches('/'), + repo_owner, + repo_name, + pr_number, + ); + + let body = serde_json::json!({ + "body": body_text, + }); + + let result = ureq::post(&url) + .set("Authorization", &format!("token {forgejo_token}")) + .set("Content-Type", "application/json") + .send_string(&body.to_string()); + + match result { + Ok(response) => { + let json: serde_json::Value = response.into_json().ok()?; + json.get("html_url")?.as_str().map(|s| s.to_string()) + } + Err(e) => { + eprintln!("StatusReporter: failed to post PR comment: {e}"); + None + } + } + } + + #[cfg(feature = "local")] + fn build_pipeline_comment( + pipeline_name: &str, + pipeline_state: &str, + commit_sha: &str, + job_outputs: &[JobOutput], + ) -> String { + let mut md = format!("## Pipeline `{pipeline_name}` — {pipeline_state}\n\n"); + let short_sha = if commit_sha.len() > 7 { + &commit_sha[..7] + } else { + commit_sha + }; + md.push_str(&format!("Commit: `{short_sha}`\n\n")); + + for job in job_outputs { + let status_label = if job.passed { + "passed".to_string() + } else { + match &job.failure_reason { + Some(reason) => format!("failed: {reason}"), + None => "failed".to_string(), + } + }; + + md.push_str(&format!( + "
\n{} — {}\n\n", + job.job_name, status_label + )); + + let max_lines = 100; + let total = job.output_lines.len(); + let lines: &[String] = if total > max_lines { + md.push_str(&format!("_Showing last {max_lines} of {total} lines_\n\n")); + &job.output_lines[total - max_lines..] + } else { + &job.output_lines + }; + + md.push_str("```\n"); + for line in lines { + md.push_str(line); + md.push('\n'); + } + md.push_str("```\n\n
\n\n"); + } + + md + } + + #[cfg(feature = "local")] + fn handle_pipeline_comment( + repo_owner: &str, + repo_name: &str, + commit_sha: &str, + branch: &str, + pipeline_name: &str, + pipeline_state: &str, + job_outputs: &[JobOutput], + forgejo_url: &str, + forgejo_token: &str, + ) { + let pr_number = match Self::find_pr_for_branch( + forgejo_url, + forgejo_token, + repo_owner, + repo_name, + branch, + ) { + Some(n) => n, + None => { + eprintln!( + "StatusReporter: no open PR for branch '{branch}', skipping comment" + ); + return; + } + }; + + let comment_body = + Self::build_pipeline_comment(pipeline_name, pipeline_state, commit_sha, job_outputs); + + let comment_url = Self::post_pr_comment( + forgejo_url, + forgejo_token, + repo_owner, + repo_name, + pr_number, + &comment_body, + ); + + // Re-post pipeline status with target_url pointing to the comment. + if let Some(ref url) = comment_url { + let update = StatusUpdate { + repo_owner: repo_owner.to_string(), + repo_name: repo_name.to_string(), + commit_sha: commit_sha.to_string(), + state: pipeline_state.to_string(), + context: format!("ci/{pipeline_name}"), + description: format!("Pipeline '{pipeline_name}' {pipeline_state}"), + target_url: Some(url.clone()), + }; + Self::post_status(&update, forgejo_url, forgejo_token); + } + } } impl ActorInterface for StatusReporter { @@ -70,6 +283,45 @@ impl ActorInterface for StatusReporter { let _ = (update, forgejo_url, forgejo_token); } } + StatusReporterMsg::PostPipelineComment { + repo_owner, + repo_name, + commit_sha, + branch, + pipeline_name, + pipeline_state, + job_outputs, + forgejo_url, + forgejo_token, + } => { + #[cfg(feature = "local")] + Self::handle_pipeline_comment( + &repo_owner, + &repo_name, + &commit_sha, + &branch, + &pipeline_name, + &pipeline_state, + &job_outputs, + &forgejo_url, + &forgejo_token, + ); + + #[cfg(not(feature = "local"))] + { + let _ = ( + repo_owner, + repo_name, + commit_sha, + branch, + pipeline_name, + pipeline_state, + job_outputs, + forgejo_url, + forgejo_token, + ); + } + } } } } diff --git a/crates/simulation/src/ci/local_sim.rs b/crates/simulation/src/ci/local_sim.rs index 7f0836e..e97bcd1 100644 --- a/crates/simulation/src/ci/local_sim.rs +++ b/crates/simulation/src/ci/local_sim.rs @@ -141,6 +141,7 @@ pub fn run_simulation(config: LocalSimConfig) -> LocalSimTrace { state: "pending".into(), context: format!("ci/{pipeline_name}"), description: format!("Pipeline '{pipeline_name}' is pending"), + target_url: None, }); pipelines.insert(pipeline_id, pipeline); @@ -182,6 +183,7 @@ pub fn run_simulation(config: LocalSimConfig) -> LocalSimTrace { state: "error".into(), context: format!("ci/{}", old_pipeline.pipeline_name), description: "superseded".into(), + target_url: None, }); completed_pipelines.push(old_pipeline); } @@ -342,6 +344,7 @@ fn schedule_next( pipeline.pipeline_name, pipeline.status.forgejo_state() ), + target_url: None, }); events.push(( diff --git a/crates/simulation/src/ci/sim.rs b/crates/simulation/src/ci/sim.rs index 0158018..3187db3 100644 --- a/crates/simulation/src/ci/sim.rs +++ b/crates/simulation/src/ci/sim.rs @@ -254,6 +254,7 @@ pub fn run_simulation(config: CiSimConfig) -> CiSimTrace { state: "pending".into(), context: format!("ci/{pipeline_name}"), description: format!("Pipeline '{pipeline_name}' is pending"), + target_url: None, }); pipelines.insert(pipeline_id, pipeline); @@ -481,6 +482,7 @@ pub fn run_simulation(config: CiSimConfig) -> CiSimTrace { pipeline.pipeline_name, pipeline.status.forgejo_state() ), + target_url: None, }); // Emit per-job skipped events.