mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-16 04:33:16 +00:00
Creating a pull request for a finished run read the projection's final patch field directly. Petri stores that patch in the blob table and the field holds a blob reference, so the description model was handed the reference string instead of the diff, and the empty-diff check could never fire. Move the reference resolution out of Files Changed into a shared final_patch::load helper and use it for both readers. Pull request input extraction now reads the real patch, judges emptiness by its text, and reports a missing or unreadable blob instead of describing a placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
4.2 KiB
Rust
119 lines
4.2 KiB
Rust
//! A run's final patch as text.
|
|
//!
|
|
//! Petri keeps the final patch in the blob table, and the projection's
|
|
//! `conclusion.diff.patch` carries a `blob://sha256/<hex>` reference to it;
|
|
//! older projections carry the patch text inline. Server readers resolve the
|
|
//! patch here so none of them mistakes the reference for patch text.
|
|
|
|
use std::borrow::Cow;
|
|
|
|
use axum::http::StatusCode;
|
|
use fabro_store::RunProjection;
|
|
use fabro_types::blob_ref;
|
|
use fabro_util::error;
|
|
|
|
use crate::error::ApiError;
|
|
use crate::server::AppState;
|
|
|
|
/// The run's final patch, or `None` when the run recorded none. A reference
|
|
/// whose bytes are missing, fail the blob store's integrity check, or are not
|
|
/// UTF-8 is an error, never an empty patch.
|
|
pub(crate) async fn load<'a>(
|
|
state: &AppState,
|
|
projection: &'a RunProjection,
|
|
) -> Result<Option<Cow<'a, str>>, ApiError> {
|
|
let Some(patch) = projection
|
|
.conclusion
|
|
.as_ref()
|
|
.and_then(|conclusion| conclusion.diff.patch.as_deref())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let Some(hash) = blob_ref::parse_blob_ref(patch.trim()) else {
|
|
return Ok(Some(Cow::Borrowed(patch)));
|
|
};
|
|
|
|
let run_id = &projection.spec.run_id;
|
|
let bytes = state
|
|
.store_ref()
|
|
.blobs()
|
|
.read(&hash)
|
|
.await
|
|
.map_err(|error| {
|
|
tracing::error!(
|
|
%run_id,
|
|
error = %error::collect_chain(&error).join(": "),
|
|
"Failed to read final patch blob"
|
|
);
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"The saved final patch could not be read.",
|
|
)
|
|
})?
|
|
.ok_or_else(|| {
|
|
tracing::error!(%run_id, "Final patch blob is missing");
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"The saved final patch is missing.",
|
|
)
|
|
})?;
|
|
let text = String::from_utf8(bytes.into()).map_err(|error| {
|
|
tracing::error!(%run_id, %error, "Final patch blob is not UTF-8");
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"The saved final patch is not valid UTF-8.",
|
|
)
|
|
})?;
|
|
Ok(Some(Cow::Owned(text)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum::http::StatusCode;
|
|
use fabro_types::blob_ref;
|
|
|
|
use super::load;
|
|
use crate::test_support::{test_app_state, test_concluded_run_projection};
|
|
|
|
#[tokio::test]
|
|
async fn inline_and_absent_patches_pass_through() {
|
|
let state = test_app_state();
|
|
let projection = test_concluded_run_projection(Some("diff --git a/x b/x\n"));
|
|
let patch = load(&state, &projection).await.unwrap();
|
|
assert_eq!(patch.as_deref(), Some("diff --git a/x b/x\n"));
|
|
|
|
let projection = test_concluded_run_projection(None);
|
|
assert!(load(&state, &projection).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blob_reference_resolves_to_its_bytes() {
|
|
let state = test_app_state();
|
|
let text = "diff --git a/x b/x\n+saved\n";
|
|
let hash = state
|
|
.store_ref()
|
|
.blobs()
|
|
.write(text.as_bytes())
|
|
.await
|
|
.unwrap();
|
|
let projection = test_concluded_run_projection(Some(&blob_ref::format_blob_ref(&hash)));
|
|
let patch = load(&state, &projection).await.unwrap();
|
|
assert_eq!(patch.as_deref(), Some(text));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_and_non_utf8_blobs_are_errors() {
|
|
let state = test_app_state();
|
|
let absent = fabro_types::BlobHash::new(b"never written");
|
|
let projection = test_concluded_run_projection(Some(&blob_ref::format_blob_ref(&absent)));
|
|
let error = load(&state, &projection).await.unwrap_err();
|
|
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
assert_eq!(error.detail(), "The saved final patch is missing.");
|
|
|
|
let hash = state.store_ref().blobs().write(&[0xff]).await.unwrap();
|
|
let projection = test_concluded_run_projection(Some(&blob_ref::format_blob_ref(&hash)));
|
|
let error = load(&state, &projection).await.unwrap_err();
|
|
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
assert_eq!(error.detail(), "The saved final patch is not valid UTF-8.");
|
|
}
|
|
}
|