Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-566-memory-rest-dto
This commit is contained in:
@@ -274,6 +274,12 @@ pub struct RegisterReviewerChildSession {
|
|||||||
pub reviewer_profile: String,
|
pub reviewer_profile: String,
|
||||||
pub now: DateTime<Utc>,
|
pub now: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReviewSubmissionAuthorization {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub subject_ref: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SubmitMergeRequestReview {
|
pub struct SubmitMergeRequestReview {
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
@@ -535,6 +541,34 @@ impl MergeRequestStore {
|
|||||||
t.commit()?;
|
t.commit()?;
|
||||||
Ok(RequestedMergeRequestReview { request_event: e })
|
Ok(RequestedMergeRequestReview { request_event: e })
|
||||||
}
|
}
|
||||||
|
pub fn authorize_review_submission(
|
||||||
|
&self,
|
||||||
|
ticket_id: &str,
|
||||||
|
capability_token: &str,
|
||||||
|
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
|
||||||
|
let connection = self.lock()?;
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT g.workspace_id,g.subject_ref
|
||||||
|
FROM merge_request_review_grants g
|
||||||
|
JOIN merge_request_ticket_relations rel
|
||||||
|
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
|
||||||
|
JOIN merge_requests mr
|
||||||
|
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
||||||
|
WHERE g.capability_token=?1 AND rel.ticket_id=?2
|
||||||
|
AND g.status='issued' AND mr.state='open'",
|
||||||
|
params![capability_token, ticket_id],
|
||||||
|
|row| {
|
||||||
|
Ok(ReviewSubmissionAuthorization {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
subject_ref: row.get(1)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| MergeRequestError::Unauthorized("review grant invalid".into()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn submit_review(
|
pub fn submit_review(
|
||||||
&self,
|
&self,
|
||||||
i: SubmitMergeRequestReview,
|
i: SubmitMergeRequestReview,
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
|||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
||||||
|
let (_d, store) = fixture();
|
||||||
|
open(&store);
|
||||||
|
request(&store, "published-source", "valid-token");
|
||||||
|
|
||||||
|
let invalid = store
|
||||||
|
.authorize_review_submission("T", "invalid-token")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
|
||||||
|
let authorized = store
|
||||||
|
.authorize_review_submission("T", "valid-token")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(authorized.workspace_id, "W");
|
||||||
|
assert_eq!(authorized.subject_ref, "published-source");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||||
let (d, s) = fixture();
|
let (d, s) = fixture();
|
||||||
|
|||||||
@@ -179,6 +179,30 @@ pub struct WorkingDirectoryRequest {
|
|||||||
pub materialization: Option<RepositoryMaterializationContext>,
|
pub materialization: Option<RepositoryMaterializationContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backend-authorized request to freshly resolve one Repository provider ref.
|
||||||
|
///
|
||||||
|
/// Runtime executes this against the registered source itself rather than a Workdir
|
||||||
|
/// or Runtime cache. Secret material is fetched through `materialization` and never
|
||||||
|
/// appears in the result.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositoryRefObservationRequest {
|
||||||
|
pub repository: WorkingDirectoryRepository,
|
||||||
|
pub selector: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub materialization: Option<RepositoryMaterializationContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-neutral proof of one freshly observed Repository ref.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositoryRefObservation {
|
||||||
|
pub repository_id: String,
|
||||||
|
pub source_revision: u64,
|
||||||
|
pub source_fingerprint: String,
|
||||||
|
pub selector: String,
|
||||||
|
pub revision_ref: String,
|
||||||
|
pub observed_at_epoch_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkingDirectoryClaim {
|
pub struct WorkingDirectoryClaim {
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
|
RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
@@ -333,6 +334,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
_request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Worker execution backend does not support Repository ref observation",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -501,6 +512,13 @@ impl WorkerExecutionBackendRef {
|
|||||||
.authorize_working_directory_repository_access(request)
|
.authorize_working_directory_repository_access(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend.observe_repository_ref(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.backend.list_working_directories()
|
self.backend.list_working_directories()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ use crate::auth::{
|
|||||||
verify_capability_token,
|
verify_capability_token,
|
||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
|
||||||
WorkspaceApiRef,
|
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
@@ -208,6 +208,7 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/working-directories/repository-access",
|
"/v1/working-directories/repository-access",
|
||||||
post(authorize_working_directory_repository_access),
|
post(authorize_working_directory_repository_access),
|
||||||
)
|
)
|
||||||
|
.route("/v1/repository-refs/observe", post(observe_repository_ref))
|
||||||
.route(
|
.route(
|
||||||
"/v1/working-directories/{working_directory_id}/sessions",
|
"/v1/working-directories/{working_directory_id}/sessions",
|
||||||
post(open_workdir_session),
|
post(open_workdir_session),
|
||||||
@@ -583,6 +584,31 @@ async fn authorize_working_directory_repository_access(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn observe_repository_ref(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Extension(auth): Extension<RuntimeAuthContext>,
|
||||||
|
body: Result<Json<RepositoryRefObservationRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<crate::catalog::RepositoryRefObservation> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
if request
|
||||||
|
.materialization
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|materialization| materialization.workspace_id != auth.workspace_id)
|
||||||
|
{
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"repository_ref_observation_workspace_mismatch",
|
||||||
|
"Repository ref observation authority does not match the authenticated Workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observation = state
|
||||||
|
.runtime
|
||||||
|
.observe_repository_ref_from_resource(request)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(observation))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_working_directories(
|
async fn list_working_directories(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||||
@@ -1750,7 +1776,10 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path == "/v1/workers" && *method == Method::POST {
|
if path == "/v1/workers" && *method == Method::POST {
|
||||||
return Some("workers:create");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
|
if (path == "/v1/working-directories/repository-access"
|
||||||
|
|| path == "/v1/repository-refs/observe")
|
||||||
|
&& *method == Method::POST
|
||||||
|
{
|
||||||
return Some("workdirs:operate");
|
return Some("workdirs:operate");
|
||||||
}
|
}
|
||||||
if path.starts_with("/v1/workdir-sessions")
|
if path.starts_with("/v1/workdir-sessions")
|
||||||
@@ -1941,6 +1970,33 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||||||
{
|
{
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if matches!(
|
||||||
|
diagnostic.code.as_str(),
|
||||||
|
"repository_ref_provider_unavailable"
|
||||||
|
| "repository_ref_provider_timeout"
|
||||||
|
| "repository_access_provider_unavailable"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
}
|
||||||
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if matches!(
|
||||||
|
diagnostic.code.as_str(),
|
||||||
|
"repository_ref_provider_auth_failed"
|
||||||
|
| "repository_access_credential_expired"
|
||||||
|
| "repository_access_credential_unavailable"
|
||||||
|
| "repository_access_credential_unauthorized"
|
||||||
|
| "repository_access_credential_invalid"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
}
|
||||||
|
RuntimeError::WorkingDirectory(diagnostic)
|
||||||
|
if diagnostic.code == "repository_ref_not_found" =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
RuntimeError::RuntimeStopped
|
RuntimeError::RuntimeStopped
|
||||||
| RuntimeError::WorkerExecutionUnavailable { .. }
|
| RuntimeError::WorkerExecutionUnavailable { .. }
|
||||||
| RuntimeError::ExecutionBackendUnavailable { .. }
|
| RuntimeError::ExecutionBackendUnavailable { .. }
|
||||||
@@ -1951,8 +2007,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||||||
| RuntimeError::InvalidInitialInputKind { .. }
|
| RuntimeError::InvalidInitialInputKind { .. }
|
||||||
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
| RuntimeError::ConfigBundleDigestMismatch { .. }
|
||||||
| RuntimeError::InvalidProfileSelector { .. }
|
| RuntimeError::InvalidProfileSelector { .. }
|
||||||
| RuntimeError::UnsupportedConfigDeclaration { .. }
|
| RuntimeError::UnsupportedConfigDeclaration { .. } => StatusCode::BAD_REQUEST,
|
||||||
| RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
|
RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
|
||||||
RuntimeError::StoreIo { .. }
|
RuntimeError::StoreIo { .. }
|
||||||
| RuntimeError::StoreMissing { .. }
|
| RuntimeError::StoreMissing { .. }
|
||||||
| RuntimeError::StoreCorrupt { .. }
|
| RuntimeError::StoreCorrupt { .. }
|
||||||
@@ -2424,6 +2480,10 @@ mod tests {
|
|||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"),
|
||||||
|
Some("workdirs:operate")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
@@ -2934,17 +2994,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_runtime_errors_preserve_diagnostic_code() {
|
fn workdir_runtime_errors_preserve_diagnostic_code() {
|
||||||
let error =
|
let cases = [
|
||||||
RuntimeError::WorkingDirectory(crate::working_directory::WorkingDirectoryDiagnostic {
|
("working_directory_not_found", StatusCode::NOT_FOUND),
|
||||||
code: "working_directory_not_found".to_string(),
|
(
|
||||||
message: "working directory missing-workdir was not found".to_string(),
|
"repository_ref_provider_timeout",
|
||||||
});
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
),
|
||||||
assert_eq!(status_for_runtime_error(&error), StatusCode::NOT_FOUND);
|
("repository_ref_provider_auth_failed", StatusCode::FORBIDDEN),
|
||||||
assert_eq!(
|
("repository_ref_not_found", StatusCode::NOT_FOUND),
|
||||||
code_for_runtime_error(&error),
|
];
|
||||||
"working_directory_not_found"
|
for (code, expected_status) in cases {
|
||||||
|
let error = RuntimeError::WorkingDirectory(
|
||||||
|
crate::working_directory::WorkingDirectoryDiagnostic {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: "bounded diagnostic".to_string(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
assert_eq!(status_for_runtime_error(&error), expected_status);
|
||||||
|
assert_eq!(code_for_runtime_error(&error), code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, RepositoryRefObservation,
|
||||||
WorkerRestoreIntent, WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
|
RepositoryRefObservationRequest, WorkerDetail, WorkerLifecycleAck, WorkerRestoreIntent,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
|
WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
|
||||||
WorkspaceApiRef,
|
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
||||||
@@ -380,6 +380,38 @@ impl Runtime {
|
|||||||
self.create_working_directory(request)
|
self.create_working_directory(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn observe_repository_ref_from_resource(
|
||||||
|
&self,
|
||||||
|
mut request: RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, RuntimeError> {
|
||||||
|
if let Some(ssh) = request
|
||||||
|
.materialization
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|materialization| materialization.ssh.as_mut())
|
||||||
|
{
|
||||||
|
self.resolve_repository_access_resource(ssh).await?;
|
||||||
|
}
|
||||||
|
self.observe_repository_ref(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "Repository ref observation requires an execution backend".to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.observe_repository_ref(&request)
|
||||||
|
.map_err(RuntimeError::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn authorize_working_directory_repository_access(
|
pub fn authorize_working_directory_repository_access(
|
||||||
&self,
|
&self,
|
||||||
request: WorkingDirectoryRepositoryAccessRequest,
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
@@ -3036,20 +3068,35 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
|
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
|
||||||
let category = match error {
|
let (code, message) = match error {
|
||||||
BackendResourceError::Expired => "expired",
|
BackendResourceError::Expired => (
|
||||||
BackendResourceError::Unauthorized { .. } => "unauthorized",
|
"repository_access_credential_expired",
|
||||||
BackendResourceError::UnsupportedKind => "unsupported_kind",
|
"Repository access credential lease expired",
|
||||||
BackendResourceError::MissingResource => "missing_resource",
|
),
|
||||||
BackendResourceError::Oversized { .. } => "oversized",
|
BackendResourceError::Unauthorized { .. } => (
|
||||||
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
|
"repository_access_credential_unauthorized",
|
||||||
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
|
"Repository access credential lease was rejected",
|
||||||
BackendResourceError::InvalidResponse { .. } => "invalid_response",
|
),
|
||||||
BackendResourceError::Transport { .. } => "transport",
|
BackendResourceError::MissingResource => (
|
||||||
|
"repository_access_credential_unavailable",
|
||||||
|
"Repository access credential lease is unavailable or already consumed",
|
||||||
|
),
|
||||||
|
BackendResourceError::Transport { .. } => (
|
||||||
|
"repository_access_provider_unavailable",
|
||||||
|
"Repository access credential provider is unavailable",
|
||||||
|
),
|
||||||
|
BackendResourceError::UnsupportedKind
|
||||||
|
| BackendResourceError::Oversized { .. }
|
||||||
|
| BackendResourceError::DigestMismatch { .. }
|
||||||
|
| BackendResourceError::ContentTypeMismatch { .. }
|
||||||
|
| BackendResourceError::InvalidResponse { .. } => (
|
||||||
|
"repository_access_credential_invalid",
|
||||||
|
"Repository access credential response is invalid",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
RuntimeError::InvalidRequest(format!(
|
RuntimeError::WorkingDirectory(
|
||||||
"Backend Repository SSH access resource fetch failed: {category}"
|
crate::working_directory::WorkingDirectoryDiagnostic::rejected(code, message),
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
|
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
|
||||||
@@ -3250,6 +3297,33 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_resource_failures_keep_typed_credential_diagnostics() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
BackendResourceError::Expired,
|
||||||
|
"repository_access_credential_expired",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
BackendResourceError::MissingResource,
|
||||||
|
"repository_access_credential_unavailable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
BackendResourceError::Unauthorized {
|
||||||
|
message: "denied".to_string(),
|
||||||
|
},
|
||||||
|
"repository_access_credential_unauthorized",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (error, expected_code) in cases {
|
||||||
|
let RuntimeError::WorkingDirectory(diagnostic) = repository_resource_error(error)
|
||||||
|
else {
|
||||||
|
panic!("Repository resource failure lost its typed diagnostic")
|
||||||
|
};
|
||||||
|
assert_eq!(diagnostic.code, expected_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn internal_worker_ref(
|
fn internal_worker_ref(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
parent_session_id: Option<&str>,
|
parent_session_id: Option<&str>,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||||
|
RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
@@ -1645,6 +1646,19 @@ where
|
|||||||
materializer.authorize_repository_access(request)
|
materializer.authorize_repository_access(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository ref observation requested, but no materializer is configured for this Runtime backend",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
materializer.observe_repository_ref(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.working_directory_materializer
|
self.working_directory_materializer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
|
MaterializerKind, RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
|
RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
@@ -196,6 +197,11 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
|
|||||||
request: &WorkingDirectoryRepositoryAccessRequest,
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
) -> Result<(), WorkingDirectoryDiagnostic>;
|
) -> Result<(), WorkingDirectoryDiagnostic>;
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic>;
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
@@ -943,6 +949,72 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer {
|
|||||||
self.cache_repository_access(&request.working_directory_id, ssh)
|
self.cache_repository_access(&request.working_directory_id, ssh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
|
||||||
|
let selector = request.selector.trim();
|
||||||
|
validate_exact_branch_selector(selector)?;
|
||||||
|
|
||||||
|
let working_request = WorkingDirectoryRequest {
|
||||||
|
repository: request.repository.clone(),
|
||||||
|
materializer: MaterializerKind::RuntimeGitCache,
|
||||||
|
backend_workdir_id: None,
|
||||||
|
materialization: request.materialization.clone(),
|
||||||
|
};
|
||||||
|
Self::validate_request(&working_request)?;
|
||||||
|
let access = RepositoryCommandAccess::prepare(&self.runtime_root, &working_request)?;
|
||||||
|
let mut command = repository_git_command(&working_request, access.as_ref());
|
||||||
|
command.args([
|
||||||
|
"ls-remote",
|
||||||
|
"--exit-code",
|
||||||
|
"--refs",
|
||||||
|
request.repository.source.uri.as_str(),
|
||||||
|
selector,
|
||||||
|
]);
|
||||||
|
let output = run_repository_git_stdout(command, request.repository.source.kind)?;
|
||||||
|
let mut lines = output.lines();
|
||||||
|
let line = lines.next().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_not_found",
|
||||||
|
"Repository provider did not return the requested ref",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if lines.next().is_some() {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned an ambiguous ref observation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (revision_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned an invalid ref observation",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if observed_selector != selector
|
||||||
|
|| !matches!(revision_ref.len(), 40 | 64)
|
||||||
|
|| !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned an invalid ref observation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RepositoryRefObservation {
|
||||||
|
repository_id: request.repository.id.clone(),
|
||||||
|
source_revision: request.repository.source_revision,
|
||||||
|
source_fingerprint: request.repository.source_fingerprint.clone(),
|
||||||
|
selector: selector.to_string(),
|
||||||
|
revision_ref: revision_ref.to_ascii_lowercase(),
|
||||||
|
observed_at_epoch_seconds: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
@@ -2022,6 +2094,146 @@ fn repository_git_command(
|
|||||||
command
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_exact_branch_selector(selector: &str) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
validate_selector(selector).map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_selector_invalid",
|
||||||
|
"Repository ref observation requires a valid exact branch selector",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !selector.starts_with("refs/heads/") {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_selector_invalid",
|
||||||
|
"Repository ref observation requires an exact branch selector",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let status = Command::new("git")
|
||||||
|
.args(["check-ref-format", selector])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Git ref validation could not be started",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_selector_invalid",
|
||||||
|
"Repository ref observation requires a valid exact branch selector",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bounded_command_output(mut reader: impl Read) -> Vec<u8> {
|
||||||
|
const MAX_CAPTURE_BYTES: usize = 8192;
|
||||||
|
let mut captured = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 4096];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(read) => {
|
||||||
|
let remaining = MAX_CAPTURE_BYTES.saturating_sub(captured.len());
|
||||||
|
captured.extend_from_slice(&chunk[..read.min(remaining)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captured
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_repository_git_stdout(
|
||||||
|
mut command: Command,
|
||||||
|
source_kind: workspace_api::RepositorySourceKind,
|
||||||
|
) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||||
|
command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command.spawn().map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider operation could not be started",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stdout = child.stdout.take().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider response could not be captured",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stderr = child.stderr.take().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider diagnostic could not be captured",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stdout_reader = std::thread::spawn(move || read_bounded_command_output(stdout));
|
||||||
|
let stderr_reader = std::thread::spawn(move || read_bounded_command_output(stderr));
|
||||||
|
let started = Instant::now();
|
||||||
|
let status = loop {
|
||||||
|
if let Some(status) = child.try_wait().map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_unavailable",
|
||||||
|
"Repository provider operation status could not be observed",
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
break status;
|
||||||
|
}
|
||||||
|
if started.elapsed() >= REPOSITORY_COMMAND_TIMEOUT {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = stdout_reader.join();
|
||||||
|
let _ = stderr_reader.join();
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_provider_timeout",
|
||||||
|
"Repository provider operation exceeded the Runtime time limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
};
|
||||||
|
let stdout = stdout_reader.join().unwrap_or_default();
|
||||||
|
let stderr = stderr_reader.join().unwrap_or_default();
|
||||||
|
if status.success() {
|
||||||
|
return String::from_utf8(stdout).map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_response_invalid",
|
||||||
|
"Repository provider returned a non-UTF-8 ref observation",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.code() == Some(2) {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"repository_ref_not_found",
|
||||||
|
"Repository provider did not return the requested ref",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let diagnostic = String::from_utf8_lossy(&stderr).to_ascii_lowercase();
|
||||||
|
let auth_failed = source_kind.is_remote()
|
||||||
|
&& [
|
||||||
|
"authentication failed",
|
||||||
|
"permission denied",
|
||||||
|
"could not read username",
|
||||||
|
"publickey",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|marker| diagnostic.contains(marker));
|
||||||
|
Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
if auth_failed {
|
||||||
|
"repository_ref_provider_auth_failed"
|
||||||
|
} else {
|
||||||
|
"repository_ref_provider_unavailable"
|
||||||
|
},
|
||||||
|
if auth_failed {
|
||||||
|
"Repository provider rejected the operation-scoped authentication"
|
||||||
|
} else {
|
||||||
|
"Repository provider operation failed"
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn run_repository_git(
|
fn run_repository_git(
|
||||||
mut command: Command,
|
mut command: Command,
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
@@ -2493,6 +2705,157 @@ mod tests {
|
|||||||
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
|
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ref_observation_reads_the_provider_fresh() {
|
||||||
|
let repo = create_clean_repo();
|
||||||
|
git(repo.path(), &["branch", "published"]);
|
||||||
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
|
let repository = request(repo.path()).repository;
|
||||||
|
let observation_request = RepositoryRefObservationRequest {
|
||||||
|
repository,
|
||||||
|
selector: "refs/heads/published".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first.revision_ref,
|
||||||
|
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
|
||||||
|
);
|
||||||
|
fs::write(repo.path().join("second.txt"), "second\n").unwrap();
|
||||||
|
git(repo.path(), &["add", "second.txt"]);
|
||||||
|
git(repo.path(), &["commit", "-m", "second"]);
|
||||||
|
git(repo.path(), &["branch", "-f", "published"]);
|
||||||
|
|
||||||
|
let second = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(first.revision_ref, second.revision_ref);
|
||||||
|
assert_eq!(
|
||||||
|
second.revision_ref,
|
||||||
|
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ref_observation_ignores_unpublished_and_stale_workdir_or_cache_refs() {
|
||||||
|
let seed = create_clean_repo();
|
||||||
|
let layout = tempfile::tempdir().unwrap();
|
||||||
|
let provider = layout.path().join("provider.git");
|
||||||
|
git(
|
||||||
|
layout.path(),
|
||||||
|
&[
|
||||||
|
"clone",
|
||||||
|
"--bare",
|
||||||
|
seed.path().to_str().unwrap(),
|
||||||
|
provider.to_str().unwrap(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let cache = layout.path().join("cache");
|
||||||
|
git(
|
||||||
|
layout.path(),
|
||||||
|
&["clone", provider.to_str().unwrap(), cache.to_str().unwrap()],
|
||||||
|
);
|
||||||
|
let workdir = layout.path().join("workdir");
|
||||||
|
git(
|
||||||
|
layout.path(),
|
||||||
|
&[
|
||||||
|
"clone",
|
||||||
|
provider.to_str().unwrap(),
|
||||||
|
workdir.to_str().unwrap(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
git(&workdir, &["config", "user.name", "Yoi Test"]);
|
||||||
|
git(&workdir, &["config", "user.email", "yoi@example.com"]);
|
||||||
|
git(&workdir, &["switch", "-c", "published-source"]);
|
||||||
|
fs::write(workdir.join("source.txt"), "first\n").unwrap();
|
||||||
|
git(&workdir, &["add", "source.txt"]);
|
||||||
|
git(&workdir, &["commit", "-m", "source first"]);
|
||||||
|
|
||||||
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
|
let repository = request(&provider).repository;
|
||||||
|
let observation_request = RepositoryRefObservationRequest {
|
||||||
|
repository,
|
||||||
|
selector: "refs/heads/published-source".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"repository_ref_not_found"
|
||||||
|
);
|
||||||
|
|
||||||
|
git(
|
||||||
|
&workdir,
|
||||||
|
&["push", "origin", "HEAD:refs/heads/published-source"],
|
||||||
|
);
|
||||||
|
let first = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(workdir.join("source.txt"), "second\n").unwrap();
|
||||||
|
git(&workdir, &["add", "source.txt"]);
|
||||||
|
git(&workdir, &["commit", "-m", "source second"]);
|
||||||
|
let unpublished_second = git_stdout(&workdir, ["rev-parse", "HEAD"]).unwrap();
|
||||||
|
let still_first = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(still_first.revision_ref, first.revision_ref);
|
||||||
|
assert_ne!(still_first.revision_ref, unpublished_second);
|
||||||
|
|
||||||
|
git(
|
||||||
|
&workdir,
|
||||||
|
&["push", "origin", "HEAD:refs/heads/published-source"],
|
||||||
|
);
|
||||||
|
let second = materializer
|
||||||
|
.observe_repository_ref(&observation_request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.revision_ref, unpublished_second);
|
||||||
|
assert_ne!(second.revision_ref, first.revision_ref);
|
||||||
|
assert_ne!(
|
||||||
|
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(),
|
||||||
|
second.revision_ref
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ref_observation_rejects_missing_and_non_branch_selectors() {
|
||||||
|
let repo = create_clean_repo();
|
||||||
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
|
||||||
|
let repository = request(repo.path()).repository;
|
||||||
|
|
||||||
|
let missing = materializer
|
||||||
|
.observe_repository_ref(&RepositoryRefObservationRequest {
|
||||||
|
repository: repository.clone(),
|
||||||
|
selector: "refs/heads/not-published".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(missing.code, "repository_ref_not_found");
|
||||||
|
let non_branch = materializer
|
||||||
|
.observe_repository_ref(&RepositoryRefObservationRequest {
|
||||||
|
repository: repository.clone(),
|
||||||
|
selector: "HEAD".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(non_branch.code, "repository_ref_selector_invalid");
|
||||||
|
let wildcard = materializer
|
||||||
|
.observe_repository_ref(&RepositoryRefObservationRequest {
|
||||||
|
repository,
|
||||||
|
selector: "refs/heads/release/*".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(wildcard.code, "repository_ref_selector_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
|
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
|
||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ use worker_runtime::RuntimeWorkspaceScope;
|
|||||||
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||||
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||||
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
|
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
|
||||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryClaim, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
|
||||||
WorkingDirectorySummary, WorkspaceApiRef,
|
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -830,6 +830,17 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
_request: RepositoryRefObservationRequest,
|
||||||
|
) -> std::result::Result<RepositoryRefObservation, Error> {
|
||||||
|
Err(Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id().to_string(),
|
||||||
|
code: "repository_ref_provider_unavailable".to_string(),
|
||||||
|
message: "Runtime does not support Repository ref observation".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
RuntimeList::new(Vec::new(), Vec::new())
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
}
|
}
|
||||||
@@ -1449,6 +1460,31 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> Result<RepositoryRefObservation, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
let runtime = self.runtime(runtime_id)?;
|
||||||
|
runtime
|
||||||
|
.observe_repository_ref(request)
|
||||||
|
.map_err(|error| match error {
|
||||||
|
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||||
|
RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "repository_ref_provider_unavailable".to_string(),
|
||||||
|
message: other.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_working_directories(
|
pub fn list_working_directories(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -2143,6 +2179,28 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> std::result::Result<RepositoryRefObservation, Error> {
|
||||||
|
self.runtime
|
||||||
|
.observe_repository_ref(request)
|
||||||
|
.map_err(|error| match error {
|
||||||
|
worker_runtime::error::RuntimeError::WorkingDirectory(diagnostic) => {
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error => Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: "repository_ref_provider_unavailable".to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
RuntimeList::new(Vec::new(), Vec::new())
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
}
|
}
|
||||||
@@ -3355,6 +3413,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: RepositoryRefObservationRequest,
|
||||||
|
) -> std::result::Result<RepositoryRefObservation, Error> {
|
||||||
|
self.post_json::<_, RepositoryRefObservation>("/v1/repository-refs/observe", &request)
|
||||||
|
.map_err(|diagnostic| Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
||||||
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
||||||
@@ -4789,6 +4859,85 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ObservingExecutionBackend {
|
||||||
|
response: RepositoryRefObservation,
|
||||||
|
observed: Arc<Mutex<Vec<RepositoryRefObservationRequest>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl worker_runtime::execution::WorkerExecutionBackend for ObservingExecutionBackend {
|
||||||
|
fn backend_id(&self) -> &str {
|
||||||
|
"repository-observation-test-backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_worker(
|
||||||
|
&self,
|
||||||
|
_request: worker_runtime::execution::WorkerExecutionSpawnRequest,
|
||||||
|
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
|
||||||
|
unreachable!("Repository observation test does not spawn Workers")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_input(
|
||||||
|
&self,
|
||||||
|
_handle: &worker_runtime::execution::WorkerExecutionHandle,
|
||||||
|
_input: EmbeddedWorkerInput,
|
||||||
|
) -> worker_runtime::execution::WorkerExecutionResult {
|
||||||
|
unreachable!("Repository observation test does not dispatch Worker input")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_repository_ref(
|
||||||
|
&self,
|
||||||
|
request: &RepositoryRefObservationRequest,
|
||||||
|
) -> Result<
|
||||||
|
RepositoryRefObservation,
|
||||||
|
worker_runtime::working_directory::WorkingDirectoryDiagnostic,
|
||||||
|
> {
|
||||||
|
self.observed.lock().unwrap().push(request.clone());
|
||||||
|
Ok(self.response.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_runtime_forwards_repository_ref_observation_to_execution_backend() {
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let expected = RepositoryRefObservation {
|
||||||
|
repository_id: "repository-1".to_string(),
|
||||||
|
source_revision: 7,
|
||||||
|
source_fingerprint: "sha256:source".to_string(),
|
||||||
|
selector: "refs/heads/published".to_string(),
|
||||||
|
revision_ref: "0123456789012345678901234567890123456789".to_string(),
|
||||||
|
observed_at_epoch_seconds: 42,
|
||||||
|
};
|
||||||
|
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||||
|
"workspace-test",
|
||||||
|
Arc::new(ObservingExecutionBackend {
|
||||||
|
response: expected.clone(),
|
||||||
|
observed: observed.clone(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = RepositoryRefObservationRequest {
|
||||||
|
repository: worker_runtime::catalog::WorkingDirectoryRepository {
|
||||||
|
id: "repository-1".to_string(),
|
||||||
|
provider: "git".to_string(),
|
||||||
|
source: workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||||
|
uri: "/provider/repository.git".to_string(),
|
||||||
|
},
|
||||||
|
source_revision: 7,
|
||||||
|
source_fingerprint: "sha256:source".to_string(),
|
||||||
|
selector: None,
|
||||||
|
},
|
||||||
|
selector: "refs/heads/published".to_string(),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime.observe_repository_ref(request.clone()).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(observed.lock().unwrap().as_slice(), &[request]);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct AcceptingExecutionBackend {
|
struct AcceptingExecutionBackend {
|
||||||
contexts:
|
contexts:
|
||||||
|
|||||||
@@ -314,12 +314,21 @@ pub struct TicketMergeRequestSummary {
|
|||||||
pub review_excerpt: Option<String>,
|
pub review_excerpt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct MergeRequestRefDiagnostic {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct MergeRequestListItem {
|
pub struct MergeRequestListItem {
|
||||||
pub summary: TicketMergeRequestSummary,
|
pub summary: TicketMergeRequestSummary,
|
||||||
pub ticket_ids: Vec<String>,
|
pub ticket_ids: Vec<String>,
|
||||||
pub thread_event_count: usize,
|
pub thread_event_count: usize,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub ref_diagnostics: Vec<MergeRequestRefDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -470,6 +479,7 @@ pub fn ticket_api_typescript() -> String {
|
|||||||
TicketAssignmentPrincipalSummary::decl(&config),
|
TicketAssignmentPrincipalSummary::decl(&config),
|
||||||
TicketActionEligibility::decl(&config),
|
TicketActionEligibility::decl(&config),
|
||||||
TicketMergeRequestSummary::decl(&config),
|
TicketMergeRequestSummary::decl(&config),
|
||||||
|
MergeRequestRefDiagnostic::decl(&config),
|
||||||
MergeRequestListItem::decl(&config),
|
MergeRequestListItem::decl(&config),
|
||||||
MergeRequestListResponse::decl(&config),
|
MergeRequestListResponse::decl(&config),
|
||||||
TicketEvidenceSummary::decl(&config),
|
TicketEvidenceSummary::decl(&config),
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ impl RepositoryRegistryReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_target_branch_selector(
|
pub(crate) fn normalize_target_branch_selector(
|
||||||
id: &str,
|
id: &str,
|
||||||
selector: &str,
|
selector: &str,
|
||||||
) -> Result<String, RepositoryLookupError> {
|
) -> Result<String, RepositoryLookupError> {
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ use crate::observation::{
|
|||||||
RuntimeObservationSource, RuntimeObservationSourceConfig,
|
RuntimeObservationSource, RuntimeObservationSourceConfig,
|
||||||
};
|
};
|
||||||
use crate::records::{
|
use crate::records::{
|
||||||
MergeRequestListItem, MergeRequestListResponse, ObjectiveDetail, ObjectiveQueryRequest,
|
MergeRequestListItem, MergeRequestListResponse, MergeRequestRefDiagnostic, ObjectiveDetail,
|
||||||
ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList, TicketDetail,
|
ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList,
|
||||||
TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
|
TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
|
||||||
};
|
};
|
||||||
use crate::repositories::{
|
use crate::repositories::{
|
||||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||||
@@ -153,10 +153,10 @@ use crate::workdir_removal::{
|
|||||||
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext,
|
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation,
|
||||||
RepositorySelector as RuntimeRepositorySelector, RepositorySshMaterializationAccess,
|
RepositoryRefObservationRequest, RepositorySelector as RuntimeRepositorySelector,
|
||||||
SensitiveString, WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
|
||||||
WorkspaceApiRef,
|
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::ConfigBundle;
|
use worker_runtime::config_bundle::ConfigBundle;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
@@ -5417,6 +5417,205 @@ fn merge_request_store(
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_ref_observation_error(error: crate::hosts::RuntimeRegistryError) -> ApiError {
|
||||||
|
match error {
|
||||||
|
crate::hosts::RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
} => Error::RuntimeOperationFailed {
|
||||||
|
runtime_id,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
other => other.into_error().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_published_merge_ref(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
repository_id: &str,
|
||||||
|
selector: &str,
|
||||||
|
) -> ApiResult<RepositoryRefObservation> {
|
||||||
|
let canonical_selector =
|
||||||
|
crate::repositories::normalize_target_branch_selector(repository_id, selector)
|
||||||
|
.map_err(repository_merge_evidence_error)?;
|
||||||
|
let operation_id = format!("repository-ref-observation-{}", Uuid::now_v7());
|
||||||
|
let repository = api
|
||||||
|
.config
|
||||||
|
.repositories
|
||||||
|
.iter()
|
||||||
|
.find(|repository| repository.id == repository_id)
|
||||||
|
.ok_or_else(|| Error::UnknownRepository(repository_id.to_string()))?;
|
||||||
|
let mut materialization_request =
|
||||||
|
working_directory_request_from_repository(repository, Some(&canonical_selector));
|
||||||
|
materialization_request.backend_workdir_id = Some(operation_id.clone());
|
||||||
|
let projection = active_repository_access_projection(api, workspace_id)?;
|
||||||
|
authorize_repository_materialization(
|
||||||
|
api,
|
||||||
|
runtime_id,
|
||||||
|
&operation_id,
|
||||||
|
&projection,
|
||||||
|
&mut materialization_request,
|
||||||
|
)?;
|
||||||
|
api.runtime
|
||||||
|
.observe_repository_ref(
|
||||||
|
runtime_id,
|
||||||
|
RepositoryRefObservationRequest {
|
||||||
|
repository: materialization_request.repository,
|
||||||
|
selector: canonical_selector,
|
||||||
|
materialization: materialization_request.materialization,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(repository_ref_observation_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_ref_error_code(code: &str) -> String {
|
||||||
|
match code {
|
||||||
|
"repository_ref_not_found" => "source_ref_not_found".to_string(),
|
||||||
|
"repository_ref_provider_timeout" => "source_ref_provider_timeout".to_string(),
|
||||||
|
"repository_ref_provider_auth_failed" => "source_ref_provider_auth_failed".to_string(),
|
||||||
|
"repository_ref_response_invalid" => "source_ref_response_invalid".to_string(),
|
||||||
|
"repository_ref_selector_invalid" => "source_ref_selector_invalid".to_string(),
|
||||||
|
"repository_access_credential_expired" => "source_ref_credential_expired".to_string(),
|
||||||
|
"repository_access_credential_unavailable" => {
|
||||||
|
"source_ref_credential_unavailable".to_string()
|
||||||
|
}
|
||||||
|
"repository_access_credential_unauthorized" => {
|
||||||
|
"source_ref_credential_unauthorized".to_string()
|
||||||
|
}
|
||||||
|
"repository_access_credential_invalid" => "source_ref_credential_invalid".to_string(),
|
||||||
|
"repository_access_provider_unavailable" | "repository_ref_provider_unavailable" => {
|
||||||
|
"source_ref_provider_unavailable".to_string()
|
||||||
|
}
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_ref_readiness_blocker(code: &str) -> &'static str {
|
||||||
|
if code == "source_ref_not_found" {
|
||||||
|
"source_ref_not_found"
|
||||||
|
} else {
|
||||||
|
"source_ref_unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remap_source_ref_error(error: ApiError) -> ApiError {
|
||||||
|
let ApiError { error, diagnostics } = error;
|
||||||
|
match error {
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
} => Error::RuntimeOperationFailed {
|
||||||
|
runtime_id,
|
||||||
|
code: source_ref_error_code(&code),
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
error => ApiError { error, diagnostics },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_published_source_ref(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
repository_id: &str,
|
||||||
|
selector: &str,
|
||||||
|
) -> ApiResult<RepositoryRefObservation> {
|
||||||
|
observe_published_merge_ref(api, workspace_id, runtime_id, repository_id, selector)
|
||||||
|
.map_err(remap_source_ref_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_assigned_workdir_source(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
assignment: &crate::store::TicketCoderAssignmentRecord,
|
||||||
|
repository_id: &str,
|
||||||
|
selector: &str,
|
||||||
|
revision_ref: &str,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
let worker = api
|
||||||
|
.runtime
|
||||||
|
.worker(&assignment.worker)
|
||||||
|
.map_err(|error| error.into_error())?;
|
||||||
|
let attached_workdir = worker.working_directory.ok_or_else(|| {
|
||||||
|
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
||||||
|
"merge_request_source_workdir_missing: current Coder has no attached Workdir".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let workdir = api
|
||||||
|
.runtime
|
||||||
|
.working_directory(
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&attached_workdir.working_directory_id,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.into_error())?
|
||||||
|
.working_directory
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
||||||
|
"merge_request_source_workdir_unavailable: current Coder Workdir could not be observed"
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.summary;
|
||||||
|
validate_assigned_workdir_source(&workdir, repository_id, selector, revision_ref).map_err(
|
||||||
|
|diagnostic| Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: assignment.worker.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_assigned_workdir_source(
|
||||||
|
workdir: &worker_runtime::catalog::WorkingDirectorySummary,
|
||||||
|
repository_id: &str,
|
||||||
|
selector: &str,
|
||||||
|
revision_ref: &str,
|
||||||
|
) -> std::result::Result<(), worker_runtime::working_directory::WorkingDirectoryDiagnostic> {
|
||||||
|
if workdir.repository_id != repository_id {
|
||||||
|
return Err(
|
||||||
|
worker_runtime::working_directory::WorkingDirectoryDiagnostic {
|
||||||
|
code: "source_workdir_repository_mismatch".to_string(),
|
||||||
|
message: "Current Coder Workdir belongs to a different Repository".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if workdir.cleanliness.as_deref() != Some("clean") {
|
||||||
|
return Err(
|
||||||
|
worker_runtime::working_directory::WorkingDirectoryDiagnostic {
|
||||||
|
code: "source_workdir_dirty".to_string(),
|
||||||
|
message: "Current Coder Workdir must be clean".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let workdir_selector_matches = workdir
|
||||||
|
.current_selector
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|workdir_selector| {
|
||||||
|
crate::repositories::normalize_target_branch_selector(repository_id, workdir_selector)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.is_some_and(|workdir_selector| {
|
||||||
|
crate::repositories::normalize_target_branch_selector(repository_id, selector)
|
||||||
|
.is_ok_and(|selector| selector == workdir_selector)
|
||||||
|
});
|
||||||
|
if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(revision_ref) {
|
||||||
|
return Err(
|
||||||
|
worker_runtime::working_directory::WorkingDirectoryDiagnostic {
|
||||||
|
code: "source_ref_revision_mismatch".to_string(),
|
||||||
|
message: "Current Coder Workdir selector and HEAD do not match the provider-published source ref".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError {
|
fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError {
|
||||||
Error::InvalidInput(format!(
|
Error::InvalidInput(format!(
|
||||||
"repository merge evidence validation failed: {error:?}"
|
"repository merge evidence validation failed: {error:?}"
|
||||||
@@ -5536,6 +5735,8 @@ struct MergeRequestRefResponse {
|
|||||||
#[serde(rename = "ref")]
|
#[serde(rename = "ref")]
|
||||||
revision_ref: Option<String>,
|
revision_ref: Option<String>,
|
||||||
observed_at: String,
|
observed_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
diagnostic: Option<MergeRequestRefDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -5615,17 +5816,44 @@ async fn scoped_list_merge_requests(
|
|||||||
limit: query.limit.unwrap_or(50),
|
limit: query.limit.unwrap_or(50),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
let reader = api.repository_reader();
|
|
||||||
let items = page
|
let items = page
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|merge_request| -> ApiResult<MergeRequestListItem> {
|
.map(|merge_request| -> ApiResult<MergeRequestListItem> {
|
||||||
let current_subject_ref = merge_request.selector_from.as_deref().and_then(|selector| {
|
let (current_subject_ref, ref_diagnostics) =
|
||||||
reader
|
if merge_request.state == merge_request::MergeRequestState::Open {
|
||||||
.observe_merge_target(&merge_request.repository_id, Some(selector))
|
match (
|
||||||
.ok()
|
merge_request.selector_from.as_deref(),
|
||||||
.map(|observation| observation.commit)
|
merge_request.ticket_ids.first(),
|
||||||
});
|
) {
|
||||||
|
(Some(selector), Some(ticket_id)) => match api
|
||||||
|
.store
|
||||||
|
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)?
|
||||||
|
{
|
||||||
|
Some(assignment) => match observe_published_source_ref(
|
||||||
|
&api,
|
||||||
|
&workspace_id,
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&merge_request.repository_id,
|
||||||
|
selector,
|
||||||
|
) {
|
||||||
|
Ok(observation) => (Some(observation.revision_ref), Vec::new()),
|
||||||
|
Err(error) => (None, vec![merge_ref_diagnostic(error)]),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
None,
|
||||||
|
vec![MergeRequestRefDiagnostic {
|
||||||
|
code: "source_ref_runtime_unavailable".to_string(),
|
||||||
|
message: "No current Coder Runtime is available to observe the source ref"
|
||||||
|
.to_string(),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
_ => (None, Vec::new()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, Vec::new())
|
||||||
|
};
|
||||||
let repository_key = api
|
let repository_key = api
|
||||||
.store
|
.store
|
||||||
.get_repository(&workspace_id, &merge_request.repository_id)?
|
.get_repository(&workspace_id, &merge_request.repository_id)?
|
||||||
@@ -5641,6 +5869,7 @@ async fn scoped_list_merge_requests(
|
|||||||
summary: merge_request_summary(merge_request, repository_key, current_subject_ref),
|
summary: merge_request_summary(merge_request, repository_key, current_subject_ref),
|
||||||
ticket_ids,
|
ticket_ids,
|
||||||
thread_event_count,
|
thread_event_count,
|
||||||
|
ref_diagnostics,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<ApiResult<Vec<_>>>()?;
|
.collect::<ApiResult<Vec<_>>>()?;
|
||||||
@@ -5650,6 +5879,55 @@ async fn scoped_list_merge_requests(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic {
|
||||||
|
let ApiError { error, diagnostics } = error;
|
||||||
|
diagnostics
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|diagnostic| MergeRequestRefDiagnostic {
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| MergeRequestRefDiagnostic {
|
||||||
|
code: "merge_ref_observation_unavailable".to_string(),
|
||||||
|
message: sanitize_backend_error(&error.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_merge_ref(code: &str, message: &str) -> MergeRequestRefResponse {
|
||||||
|
MergeRequestRefResponse {
|
||||||
|
status: "unknown".to_string(),
|
||||||
|
revision_ref: None,
|
||||||
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
|
diagnostic: Some(MergeRequestRefDiagnostic {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_merge_ref_response(error: ApiError) -> MergeRequestRefResponse {
|
||||||
|
MergeRequestRefResponse {
|
||||||
|
status: "unknown".to_string(),
|
||||||
|
revision_ref: None,
|
||||||
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
|
diagnostic: Some(merge_ref_diagnostic(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_ref_response(observation: RepositoryRefObservation) -> MergeRequestRefResponse {
|
||||||
|
let observed_at =
|
||||||
|
chrono::DateTime::<Utc>::from_timestamp(observation.observed_at_epoch_seconds as i64, 0)
|
||||||
|
.unwrap_or_else(Utc::now)
|
||||||
|
.to_rfc3339();
|
||||||
|
MergeRequestRefResponse {
|
||||||
|
status: "known".into(),
|
||||||
|
revision_ref: Some(observation.revision_ref),
|
||||||
|
observed_at,
|
||||||
|
diagnostic: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_show_merge_request(
|
async fn scoped_show_merge_request(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||||
@@ -5665,38 +5943,53 @@ async fn scoped_show_merge_request(
|
|||||||
query.after,
|
query.after,
|
||||||
query.limit.unwrap_or(100),
|
query.limit.unwrap_or(100),
|
||||||
)?;
|
)?;
|
||||||
let reader = api.repository_reader();
|
let ticket_id = mr.ticket_ids.first().ok_or_else(|| {
|
||||||
let observed_at = Utc::now().to_rfc3339();
|
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
||||||
let source = match mr.selector_from.as_deref() {
|
"Merge Request has no linked Ticket".into(),
|
||||||
Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) {
|
))
|
||||||
Ok(value) => MergeRequestRefResponse {
|
})?;
|
||||||
status: "known".into(),
|
let assignment = api
|
||||||
revision_ref: Some(value.commit),
|
.store
|
||||||
observed_at: observed_at.clone(),
|
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)?;
|
||||||
},
|
let source = match (mr.selector_from.as_deref(), assignment.as_ref()) {
|
||||||
Err(_) => MergeRequestRefResponse {
|
(Some(selector), Some(assignment)) => {
|
||||||
status: "unknown".into(),
|
match observe_published_source_ref(
|
||||||
revision_ref: None,
|
&api,
|
||||||
observed_at: observed_at.clone(),
|
&workspace_id,
|
||||||
},
|
&assignment.worker.runtime_id,
|
||||||
},
|
&mr.repository_id,
|
||||||
None => MergeRequestRefResponse {
|
selector,
|
||||||
|
) {
|
||||||
|
Ok(observation) => merge_ref_response(observation),
|
||||||
|
Err(error) => unknown_merge_ref_response(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), None) => unknown_merge_ref(
|
||||||
|
"source_ref_runtime_unavailable",
|
||||||
|
"No current Coder Runtime is available to observe the source ref",
|
||||||
|
),
|
||||||
|
(None, _) => MergeRequestRefResponse {
|
||||||
status: "requires_repair".into(),
|
status: "requires_repair".into(),
|
||||||
revision_ref: None,
|
revision_ref: None,
|
||||||
observed_at: observed_at.clone(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
|
diagnostic: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) {
|
let target = match assignment.as_ref() {
|
||||||
Ok(value) => MergeRequestRefResponse {
|
Some(assignment) => match observe_published_merge_ref(
|
||||||
status: "known".into(),
|
&api,
|
||||||
revision_ref: Some(value.commit),
|
&workspace_id,
|
||||||
observed_at,
|
&assignment.worker.runtime_id,
|
||||||
},
|
&mr.repository_id,
|
||||||
Err(_) => MergeRequestRefResponse {
|
&mr.selector_to,
|
||||||
status: "unknown".into(),
|
) {
|
||||||
revision_ref: None,
|
Ok(observation) => merge_ref_response(observation),
|
||||||
observed_at,
|
Err(error) => unknown_merge_ref_response(error),
|
||||||
},
|
},
|
||||||
|
None => unknown_merge_ref(
|
||||||
|
"target_ref_runtime_unavailable",
|
||||||
|
"No current Coder Runtime is available to observe the target ref",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let linked_tickets = mr
|
let linked_tickets = mr
|
||||||
.ticket_ids
|
.ticket_ids
|
||||||
@@ -5729,13 +6022,28 @@ async fn scoped_merge_request_readiness(
|
|||||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||||
let current_subject_ref = mr.selector_from.as_deref().and_then(|selector| {
|
let assignment = api
|
||||||
api.repository_reader()
|
.store
|
||||||
.observe_merge_target(&mr.repository_id, Some(selector))
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
|
||||||
.ok()
|
let (current_subject_ref, source_blocker) = match (mr.selector_from.as_deref(), assignment) {
|
||||||
.map(|v| v.commit)
|
(Some(selector), Some(assignment)) => match observe_published_source_ref(
|
||||||
});
|
&api,
|
||||||
Ok(Json(store.readiness(merge_request::ReadinessCheck {
|
&workspace_id,
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&mr.repository_id,
|
||||||
|
selector,
|
||||||
|
) {
|
||||||
|
Ok(observation) => (Some(observation.revision_ref), None),
|
||||||
|
Err(error) => {
|
||||||
|
let diagnostic = merge_ref_diagnostic(error);
|
||||||
|
let blocker = source_ref_readiness_blocker(&diagnostic.code);
|
||||||
|
(None, Some(blocker.to_string()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(Some(_), None) => (None, Some("source_ref_unavailable".to_string())),
|
||||||
|
(None, _) => (None, None),
|
||||||
|
};
|
||||||
|
let mut report = store.readiness(merge_request::ReadinessCheck {
|
||||||
ticket_id,
|
ticket_id,
|
||||||
current_subject_ref,
|
current_subject_ref,
|
||||||
auth: merge_request::MergeRequestAuth {
|
auth: merge_request::MergeRequestAuth {
|
||||||
@@ -5745,7 +6053,17 @@ async fn scoped_merge_request_readiness(
|
|||||||
worker_id: String::new(),
|
worker_id: String::new(),
|
||||||
assignment_id: String::new(),
|
assignment_id: String::new(),
|
||||||
},
|
},
|
||||||
})?))
|
})?;
|
||||||
|
if let Some(blocker) = source_blocker {
|
||||||
|
report
|
||||||
|
.blockers
|
||||||
|
.retain(|current| current != "selector_unresolved");
|
||||||
|
if !report.blockers.contains(&blocker) {
|
||||||
|
report.blockers.push(blocker);
|
||||||
|
}
|
||||||
|
report.ready = false;
|
||||||
|
}
|
||||||
|
Ok(Json(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_open_merge_request(
|
async fn scoped_open_merge_request(
|
||||||
@@ -5785,13 +6103,27 @@ async fn scoped_open_merge_request(
|
|||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let reader = api.repository_reader();
|
let source_observation = observe_published_source_ref(
|
||||||
reader
|
&api,
|
||||||
.observe_merge_target(&repository_id, Some(&input.selector_from))
|
&workspace_id,
|
||||||
.map_err(repository_merge_evidence_error)?;
|
&assignment.worker.runtime_id,
|
||||||
reader
|
&repository_id,
|
||||||
.observe_merge_target(&repository_id, Some(&input.selector_to))
|
&input.selector_from,
|
||||||
.map_err(repository_merge_evidence_error)?;
|
)?;
|
||||||
|
require_assigned_workdir_source(
|
||||||
|
&api,
|
||||||
|
&assignment,
|
||||||
|
&repository_id,
|
||||||
|
&input.selector_from,
|
||||||
|
&source_observation.revision_ref,
|
||||||
|
)?;
|
||||||
|
observe_published_merge_ref(
|
||||||
|
&api,
|
||||||
|
&workspace_id,
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&repository_id,
|
||||||
|
&input.selector_to,
|
||||||
|
)?;
|
||||||
let merge_request = merge_request_store(&api, &workspace_id)?.open_merge_request(
|
let merge_request = merge_request_store(&api, &workspace_id)?.open_merge_request(
|
||||||
merge_request::OpenMergeRequest {
|
merge_request::OpenMergeRequest {
|
||||||
merge_request_id: Uuid::now_v7().to_string(),
|
merge_request_id: Uuid::now_v7().to_string(),
|
||||||
@@ -5850,11 +6182,20 @@ async fn scoped_repair_merge_request_selector(
|
|||||||
}
|
}
|
||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||||
let resolved_subject_ref = api
|
let assignment = api
|
||||||
.repository_reader()
|
.store
|
||||||
.observe_merge_target(&mr.repository_id, Some(&input.selector_from))
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
.map_err(repository_merge_evidence_error)?
|
.ok_or_else(|| {
|
||||||
.commit;
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||||
|
})?;
|
||||||
|
let resolved_subject_ref = observe_published_source_ref(
|
||||||
|
&api,
|
||||||
|
&workspace_id,
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&mr.repository_id,
|
||||||
|
&input.selector_from,
|
||||||
|
)?
|
||||||
|
.revision_ref;
|
||||||
let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom {
|
let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom {
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
ticket_id,
|
ticket_id,
|
||||||
@@ -5922,11 +6263,21 @@ async fn scoped_register_merge_request_review_capability(
|
|||||||
.selector_from
|
.selector_from
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
|
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
|
||||||
let subject_ref = api
|
let source_observation = observe_published_source_ref(
|
||||||
.repository_reader()
|
&api,
|
||||||
.observe_merge_target(&mr.repository_id, Some(selector))
|
&workspace_id,
|
||||||
.map_err(repository_merge_evidence_error)?
|
&assignment.worker.runtime_id,
|
||||||
.commit;
|
&mr.repository_id,
|
||||||
|
selector,
|
||||||
|
)?;
|
||||||
|
require_assigned_workdir_source(
|
||||||
|
&api,
|
||||||
|
&assignment,
|
||||||
|
&mr.repository_id,
|
||||||
|
selector,
|
||||||
|
&source_observation.revision_ref,
|
||||||
|
)?;
|
||||||
|
let subject_ref = source_observation.revision_ref;
|
||||||
store.request_review(merge_request::RequestMergeRequestReview {
|
store.request_review(merge_request::RequestMergeRequestReview {
|
||||||
ticket_id,
|
ticket_id,
|
||||||
subject_ref,
|
subject_ref,
|
||||||
@@ -5952,16 +6303,35 @@ async fn scoped_submit_merge_request_review(
|
|||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
|
let review_authorization =
|
||||||
|
store.authorize_review_submission(&ticket_id, &input.capability_token)?;
|
||||||
|
if review_authorization.workspace_id != workspace_id {
|
||||||
|
return Err(
|
||||||
|
Error::MergeRequest(merge_request::MergeRequestError::Unauthorized(
|
||||||
|
"review grant invalid".into(),
|
||||||
|
))
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||||
let selector = mr
|
let selector = mr
|
||||||
.selector_from
|
.selector_from
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
|
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
|
||||||
let current_subject_ref = api
|
let assignment = api
|
||||||
.repository_reader()
|
.store
|
||||||
.observe_merge_target(&mr.repository_id, Some(selector))
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
.map_err(repository_merge_evidence_error)?
|
.ok_or_else(|| {
|
||||||
.commit;
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||||
|
})?;
|
||||||
|
let current_subject_ref = observe_published_source_ref(
|
||||||
|
&api,
|
||||||
|
&workspace_id,
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&mr.repository_id,
|
||||||
|
selector,
|
||||||
|
)?
|
||||||
|
.revision_ref;
|
||||||
Ok(Json(store.submit_review(
|
Ok(Json(store.submit_review(
|
||||||
merge_request::SubmitMergeRequestReview {
|
merge_request::SubmitMergeRequestReview {
|
||||||
ticket_id,
|
ticket_id,
|
||||||
@@ -6034,7 +6404,6 @@ async fn scoped_complete_merge_request(
|
|||||||
require_online_workspace_orchestrator_source(&api, &source)?;
|
require_online_workspace_orchestrator_source(&api, &source)?;
|
||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
let mr = store.get(&workspace_id, &ticket_id)?;
|
let mr = store.get(&workspace_id, &ticket_id)?;
|
||||||
let repositories = api.repository_reader();
|
|
||||||
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
|
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
|
||||||
let replay = merge_request::CompleteMergeRequest {
|
let replay = merge_request::CompleteMergeRequest {
|
||||||
ticket_id,
|
ticket_id,
|
||||||
@@ -6066,15 +6435,23 @@ async fn scoped_complete_merge_request(
|
|||||||
.selector_from
|
.selector_from
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
|
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
|
||||||
let current_source_ref = repositories
|
let current_source_ref = observe_published_source_ref(
|
||||||
.observe_merge_target(&mr.repository_id, Some(selector))
|
&api,
|
||||||
.map_err(repository_merge_evidence_error)?
|
&workspace_id,
|
||||||
.commit;
|
&assignment.worker.runtime_id,
|
||||||
let observed = repositories
|
&mr.repository_id,
|
||||||
.observe_merge_target(&mr.repository_id, Some(&mr.selector_to))
|
selector,
|
||||||
.map_err(repository_merge_evidence_error)?;
|
)?
|
||||||
|
.revision_ref;
|
||||||
|
let observed_target = observe_published_merge_ref(
|
||||||
|
&api,
|
||||||
|
&workspace_id,
|
||||||
|
&assignment.worker.runtime_id,
|
||||||
|
&mr.repository_id,
|
||||||
|
&mr.selector_to,
|
||||||
|
)?;
|
||||||
require_completed_target_observation(
|
require_completed_target_observation(
|
||||||
&observed.commit,
|
&observed_target.revision_ref,
|
||||||
&input.target_ref_before,
|
&input.target_ref_before,
|
||||||
&input.target_ref_after,
|
&input.target_ref_after,
|
||||||
)?;
|
)?;
|
||||||
@@ -16300,6 +16677,126 @@ mod tests {
|
|||||||
SqliteWorkspaceStore, TrustedRuntimeRecord, UserRecord, WorkspaceRecord,
|
SqliteWorkspaceStore, TrustedRuntimeRecord, UserRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn handler_source<'a>(source: &'a str, name: &str) -> &'a str {
|
||||||
|
let start = source
|
||||||
|
.find(&format!("async fn {name}"))
|
||||||
|
.unwrap_or_else(|| panic!("missing handler {name}"));
|
||||||
|
let tail = &source[start..];
|
||||||
|
let end = tail[1..]
|
||||||
|
.find("\nasync fn ")
|
||||||
|
.map(|offset| offset + 1)
|
||||||
|
.unwrap_or(tail.len());
|
||||||
|
&tail[..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_request_http_paths_observe_refs_through_runtime_provider_authority() {
|
||||||
|
let source = include_str!("server.rs");
|
||||||
|
for handler in [
|
||||||
|
"scoped_list_merge_requests",
|
||||||
|
"scoped_show_merge_request",
|
||||||
|
"scoped_open_merge_request",
|
||||||
|
"scoped_repair_merge_request_selector",
|
||||||
|
"scoped_register_merge_request_review_capability",
|
||||||
|
"scoped_submit_merge_request_review",
|
||||||
|
"scoped_complete_merge_request",
|
||||||
|
] {
|
||||||
|
let handler = handler_source(source, handler);
|
||||||
|
assert!(
|
||||||
|
handler.contains("observe_published_source_ref(")
|
||||||
|
|| handler.contains("observe_published_merge_ref(")
|
||||||
|
);
|
||||||
|
assert!(!handler.contains("repository_reader()"));
|
||||||
|
}
|
||||||
|
let submit = handler_source(source, "scoped_submit_merge_request_review");
|
||||||
|
assert!(
|
||||||
|
submit
|
||||||
|
.find("authorize_review_submission(")
|
||||||
|
.is_some_and(|authorization| {
|
||||||
|
submit
|
||||||
|
.find("observe_published_source_ref(")
|
||||||
|
.is_some_and(|observation| authorization < observation)
|
||||||
|
}),
|
||||||
|
"review capability must be validated before provider access",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_distinguishes_missing_source_from_unavailable_provider() {
|
||||||
|
assert_eq!(
|
||||||
|
source_ref_readiness_blocker("source_ref_not_found"),
|
||||||
|
"source_ref_not_found"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source_ref_error_code("repository_ref_not_found"),
|
||||||
|
"source_ref_not_found"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source_ref_error_code("repository_ref_provider_timeout"),
|
||||||
|
"source_ref_provider_timeout"
|
||||||
|
);
|
||||||
|
for code in [
|
||||||
|
"source_ref_provider_unavailable",
|
||||||
|
"source_ref_provider_timeout",
|
||||||
|
"source_ref_provider_auth_failed",
|
||||||
|
"source_ref_credential_expired",
|
||||||
|
] {
|
||||||
|
assert_eq!(source_ref_readiness_blocker(code), "source_ref_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assigned_workdir_must_be_clean_and_match_published_source() {
|
||||||
|
let mut workdir = worker_runtime::catalog::WorkingDirectorySummary {
|
||||||
|
working_directory_id: "workdir-1".to_string(),
|
||||||
|
repository_id: "repository-1".to_string(),
|
||||||
|
creation_selector: Some("work/T-549".to_string()),
|
||||||
|
creation_ref: Some("abc123".to_string()),
|
||||||
|
creation_tree: None,
|
||||||
|
current_selector: Some("work/T-549".to_string()),
|
||||||
|
current_ref: Some("abc123".to_string()),
|
||||||
|
current_tree: None,
|
||||||
|
observed_at_epoch_seconds: Some(1_767_225_600),
|
||||||
|
materializer_kind: workspace_api::WorkingDirectoryMaterializerKind::RuntimeGitCache,
|
||||||
|
cleanup_target: None,
|
||||||
|
status: worker_runtime::catalog::WorkingDirectoryStatusKind::Active,
|
||||||
|
cleanliness: Some("clean".to_string()),
|
||||||
|
primary_worker_id: Some("worker-1".to_string()),
|
||||||
|
occupied_by: None,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
validate_assigned_workdir_source(
|
||||||
|
&workdir,
|
||||||
|
"repository-1",
|
||||||
|
"refs/heads/work/T-549",
|
||||||
|
"abc123",
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
|
||||||
|
workdir.cleanliness = Some("dirty".to_string());
|
||||||
|
assert!(matches!(
|
||||||
|
validate_assigned_workdir_source(
|
||||||
|
&workdir,
|
||||||
|
"repository-1",
|
||||||
|
"work/T-549",
|
||||||
|
"abc123"
|
||||||
|
),
|
||||||
|
Err(diagnostic) if diagnostic.code == "source_workdir_dirty"
|
||||||
|
));
|
||||||
|
workdir.cleanliness = Some("clean".to_string());
|
||||||
|
workdir.current_ref = Some("different".to_string());
|
||||||
|
assert!(matches!(
|
||||||
|
validate_assigned_workdir_source(
|
||||||
|
&workdir,
|
||||||
|
"repository-1",
|
||||||
|
"work/T-549",
|
||||||
|
"abc123"
|
||||||
|
),
|
||||||
|
Err(diagnostic) if diagnostic.code == "source_ref_revision_mismatch"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn completed_upload_file(sha256: &str) -> protocol::UploadedFileRef {
|
fn completed_upload_file(sha256: &str) -> protocol::UploadedFileRef {
|
||||||
protocol::UploadedFileRef {
|
protocol::UploadedFileRef {
|
||||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3".into(),
|
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3".into(),
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_un
|
|||||||
|
|
||||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||||
|
|
||||||
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
|
export type MergeRequestRefDiagnostic = { code: string, message: string, };
|
||||||
|
|
||||||
|
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, ref_diagnostics?: Array<MergeRequestRefDiagnostic>, };
|
||||||
|
|
||||||
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
|
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user