fix(ticket): project immutable results and typed source observations
T-716: merged evidence uses recorded result approval; open source reads use Runtime provider authority and preserve unavailability. Current requirements use the shared exact snapshot evaluator. Remove primary-MR/report SQL heuristics; continue bounded query scans through nonmatching candidates. Align Worker/Web projections and explain lifecycle versus integration/attestation. Synchronize checker test on delivered inputs, not pre-delivery attempt state. Validation: root cargo check; cargo test -p merge-request (30), -p server-api (70), -p worker (949), -p yoi-workspace-server (620 lib + 11 binary); server-api typescript feature check; 32 focused Deno tests; deno task check (0 errors/warnings); cargo fmt --all -- --check; git diff --check HEAD; canonical OpenAPI/generated Ticket artifact checks.
This commit is contained in:
@@ -7404,6 +7404,15 @@ pub struct TicketActionEligibility {
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum TicketSourceRefObservation {
|
||||
Observed {},
|
||||
Unavailable { code: String },
|
||||
NotRequired {},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -7416,6 +7425,8 @@ pub struct TicketMergeRequestSummary {
|
||||
pub selector_to: String,
|
||||
pub updated_at: String,
|
||||
pub current_subject_ref: Option<String>,
|
||||
pub source_ref_observation: TicketSourceRefObservation,
|
||||
pub integration_evidence_error: Option<String>,
|
||||
pub review_subject_ref: Option<String>,
|
||||
pub review_requested_at: Option<String>,
|
||||
pub review_submitted_at: Option<String>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use server_api::TicketSourceRefObservation;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketQueryResponse {
|
||||
@@ -168,6 +169,8 @@ struct ModelMergeRequest {
|
||||
selector_to: String,
|
||||
review_status: String,
|
||||
subject_ref: Option<String>,
|
||||
source_ref_observation: TicketSourceRefObservation,
|
||||
integration_evidence_error: Option<String>,
|
||||
review_excerpt: Option<String>,
|
||||
}
|
||||
|
||||
@@ -179,6 +182,7 @@ struct ModelTicketEvidence {
|
||||
has_commit: bool,
|
||||
review_status: Option<String>,
|
||||
approved_current_subject: bool,
|
||||
review_after_rescope: bool,
|
||||
unresolved_request_changes: bool,
|
||||
complete_for_integration: bool,
|
||||
missing: Vec<String>,
|
||||
@@ -517,6 +521,14 @@ fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
selector_to: string_field(merge, "selector_to")?,
|
||||
review_status: string_field(merge, "review_status")?,
|
||||
subject_ref: optional_string(merge, "current_subject_ref")?,
|
||||
source_ref_observation: serde_json::from_value(
|
||||
merge
|
||||
.get("source_ref_observation")
|
||||
.ok_or_else(|| "missing source_ref_observation".to_string())?
|
||||
.clone(),
|
||||
)
|
||||
.map_err(|error| format!("invalid source_ref_observation: {error}"))?,
|
||||
integration_evidence_error: optional_string(merge, "integration_evidence_error")?,
|
||||
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||
})
|
||||
}
|
||||
@@ -530,6 +542,7 @@ fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||
has_commit: bool_field(evidence, "has_commit")?,
|
||||
review_status: optional_string(evidence, "review_status")?,
|
||||
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
|
||||
review_after_rescope: bool_field(evidence, "review_after_rescope")?,
|
||||
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
|
||||
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
||||
missing: string_array(evidence, "missing")?,
|
||||
@@ -735,6 +748,7 @@ mod tests {
|
||||
"has_commit": false,
|
||||
"review_status": null,
|
||||
"approved_current_subject": false,
|
||||
"review_after_rescope": false,
|
||||
"unresolved_request_changes": false,
|
||||
"complete_for_integration": false,
|
||||
"missing": ["merge_request"]
|
||||
@@ -817,6 +831,7 @@ mod tests {
|
||||
"has_commit": false,
|
||||
"review_status": null,
|
||||
"approved_current_subject": false,
|
||||
"review_after_rescope": false,
|
||||
"unresolved_request_changes": false,
|
||||
"complete_for_integration": false,
|
||||
"missing": ["merge_request"]
|
||||
@@ -844,6 +859,123 @@ mod tests {
|
||||
assert!(!projected.to_string().contains("internal-blocker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_projection_preserves_source_diagnostics_separately_from_rescope_attestation() {
|
||||
for (state, subject_ref, observation, integration_error, attested) in [
|
||||
(
|
||||
"merged",
|
||||
Some("immutable-approved-source"),
|
||||
json!({"status": "not_required"}),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"open",
|
||||
None,
|
||||
json!({"status": "unavailable", "code": "source_ref_unavailable"}),
|
||||
Some("source_ref_unavailable"),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"open",
|
||||
Some("observed-source"),
|
||||
json!({"status": "observed"}),
|
||||
None,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let merged = state == "merged";
|
||||
let merge_request = json!({
|
||||
"merge_request_id": "mr-source",
|
||||
"repository_key": "repository",
|
||||
"state": state,
|
||||
"selector_from": "source-branch",
|
||||
"selector_to": "main",
|
||||
"review_status": "approved",
|
||||
"current_subject_ref": subject_ref,
|
||||
"source_ref_observation": observation,
|
||||
"integration_evidence_error": integration_error,
|
||||
"review_excerpt": "Reviewed source"
|
||||
});
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-716",
|
||||
"item_revision": "current-ticket-revision",
|
||||
"title": "Source evidence",
|
||||
"body": "Body",
|
||||
"state": if merged { "done" } else { "inprogress" },
|
||||
"events": [],
|
||||
"linked_objectives": [],
|
||||
"assignments": [],
|
||||
"implementation_reports": [],
|
||||
"merge_requests": [merge_request.clone()],
|
||||
"merge_request": merge_request,
|
||||
"evidence": {
|
||||
"has_merge_request": true,
|
||||
"has_current_subject_ref": subject_ref.is_some(),
|
||||
"has_review_request": true,
|
||||
"has_commit": subject_ref.is_some(),
|
||||
"review_status": "approved",
|
||||
"approved_current_subject": subject_ref.is_some(),
|
||||
"review_after_rescope": attested,
|
||||
"unresolved_request_changes": false,
|
||||
"complete_for_integration": merged,
|
||||
"missing": if subject_ref.is_none() {
|
||||
vec!["current_subject_ref"]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("source evidence must project");
|
||||
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
|
||||
|
||||
for merge in [&projected["merge_requests"][0], &projected["merge_request"]] {
|
||||
assert_eq!(merge["subject_ref"], json!(subject_ref));
|
||||
assert_eq!(merge["source_ref_observation"], observation);
|
||||
assert_eq!(
|
||||
merge.get("integration_evidence_error"),
|
||||
Some(&json!(integration_error))
|
||||
);
|
||||
}
|
||||
assert_eq!(projected["item_revision"], "current-ticket-revision");
|
||||
assert_eq!(projected["evidence"]["review_after_rescope"], attested);
|
||||
assert_eq!(projected["evidence"]["complete_for_integration"], merged);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_request_projection_rejects_missing_or_invalid_source_observation() {
|
||||
let merge_request = json!({
|
||||
"merge_request_id": "mr-source",
|
||||
"repository_key": "repository",
|
||||
"state": "open",
|
||||
"selector_to": "main",
|
||||
"review_status": "none",
|
||||
"current_subject_ref": null,
|
||||
"integration_evidence_error": null
|
||||
});
|
||||
assert!(
|
||||
project_merge_request(&merge_request)
|
||||
.expect_err("source observation is required")
|
||||
.contains("source_ref_observation")
|
||||
);
|
||||
for observation in [
|
||||
Value::Null,
|
||||
json!({"status": "unknown"}),
|
||||
json!({"status": "unavailable"}),
|
||||
json!({"status": "unavailable", "code": 42}),
|
||||
json!({"status": "observed", "code": "unexpected"}),
|
||||
] {
|
||||
let mut merge_request = merge_request.clone();
|
||||
merge_request["source_ref_observation"] = observation;
|
||||
assert!(
|
||||
project_merge_request(&merge_request)
|
||||
.expect_err("invalid source observation must fail")
|
||||
.contains("source_ref_observation")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_projection_accepts_current_workspace_api_shapes() {
|
||||
let outgoing = project_relation(
|
||||
|
||||
@@ -101,6 +101,7 @@ enum WorkspaceTicketEvidenceFilter {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketReviewFilter {
|
||||
None,
|
||||
Unknown,
|
||||
Pending,
|
||||
Approved,
|
||||
RequestChanges,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3471,7 +3471,7 @@ impl WorkspaceApi {
|
||||
.map_err(|error| {
|
||||
Error::Store(format!("failed to register subjektiv storage: {error}"))
|
||||
})?;
|
||||
let api = Self {
|
||||
let mut api = Self {
|
||||
config_store,
|
||||
repository_secrets,
|
||||
signing_identities,
|
||||
@@ -3483,11 +3483,7 @@ impl WorkspaceApi {
|
||||
authority: SqliteWorkspaceAuthority::new(
|
||||
config.database_path.clone(),
|
||||
config.workspace_id.clone(),
|
||||
)?
|
||||
.with_merge_revision_source(Arc::new(MergeRequestRepositorySource {
|
||||
workspace_id: config.workspace_id.clone(),
|
||||
reader: RepositoryRegistryReader::new(config.repositories.clone()),
|
||||
})),
|
||||
)?,
|
||||
_worker_projection_shutdown: Arc::new(WorkerProjectionShutdownGuard {
|
||||
service: Arc::downgrade(&worker_projection),
|
||||
}),
|
||||
@@ -3513,6 +3509,14 @@ impl WorkspaceApi {
|
||||
worker_control_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
attachment_upload_grants: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
// The observer snapshot retains the unconfigured authority, not itself:
|
||||
// source observation uses Repository access + Runtime authority without a cycle.
|
||||
let observer_api = api.clone();
|
||||
api.authority =
|
||||
api.authority
|
||||
.with_merge_revision_source(Arc::new(RuntimeTicketMergeRevisionSource {
|
||||
api: observer_api,
|
||||
}));
|
||||
if let Some(dispatcher) = worker_remove_dispatcher {
|
||||
dispatcher
|
||||
.install_executor(Arc::new(
|
||||
@@ -14645,12 +14649,36 @@ struct MergeRequestRepositorySource {
|
||||
reader: RepositoryRegistryReader,
|
||||
}
|
||||
|
||||
impl TicketMergeRevisionSource for MergeRequestRepositorySource {
|
||||
fn resolve_subject_ref(&self, repository_id: &str, selector: &str) -> Option<String> {
|
||||
self.reader
|
||||
.observe_merge_target(repository_id, Some(selector))
|
||||
.ok()
|
||||
.map(|target| target.commit)
|
||||
#[derive(Clone)]
|
||||
struct RuntimeTicketMergeRevisionSource {
|
||||
api: WorkspaceApi,
|
||||
}
|
||||
|
||||
impl TicketMergeRevisionSource for RuntimeTicketMergeRevisionSource {
|
||||
fn resolve_subject_ref(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
repository_id: &str,
|
||||
selector: &str,
|
||||
) -> std::result::Result<String, MergeRequestRefDiagnostic> {
|
||||
let assignment = self
|
||||
.api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(self.api.workspace_id(), ticket_id)
|
||||
.map_err(|error| merge_ref_diagnostic(error.into()))?
|
||||
.ok_or_else(|| MergeRequestRefDiagnostic {
|
||||
code: "source_ref_runtime_unavailable".into(),
|
||||
message: "No current Coder Runtime is available to observe the source ref".into(),
|
||||
})?;
|
||||
observe_published_source_ref(
|
||||
&self.api,
|
||||
self.api.workspace_id(),
|
||||
&assignment.worker.runtime_id,
|
||||
repository_id,
|
||||
selector,
|
||||
)
|
||||
.map(|observation| observation.revision_ref)
|
||||
.map_err(merge_ref_diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15366,7 +15394,11 @@ async fn scoped_list_merge_requests(
|
||||
let ticket_ids = merge_request.ticket_ids.clone();
|
||||
let thread_event_count = merge_request.thread.len();
|
||||
Ok(MergeRequestListItem {
|
||||
summary: merge_request_summary(merge_request, repository_key, current_subject_ref),
|
||||
summary: merge_request_summary(merge_request, repository_key, current_subject_ref.ok_or_else(|| {
|
||||
ref_diagnostics.first().cloned().unwrap_or_else(|| MergeRequestRefDiagnostic {
|
||||
code: "source_ref_unavailable".into(), message: "Source observation is unavailable".into(),
|
||||
})
|
||||
})),
|
||||
ticket_ids,
|
||||
thread_event_count,
|
||||
ref_diagnostics,
|
||||
@@ -15389,7 +15421,10 @@ fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic {
|
||||
message: diagnostic.message,
|
||||
})
|
||||
.unwrap_or_else(|| MergeRequestRefDiagnostic {
|
||||
code: "merge_ref_observation_unavailable".to_string(),
|
||||
code: match &error {
|
||||
Error::RuntimeOperationFailed { code, .. } => code.clone(),
|
||||
_ => "merge_ref_observation_unavailable".to_string(),
|
||||
},
|
||||
message: sanitize_backend_error(&error.to_string()),
|
||||
})
|
||||
}
|
||||
@@ -15430,6 +15465,50 @@ fn merge_ref_response(
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted results are independent of provider availability and mutable selectors.
|
||||
fn integrated_merge_refs(
|
||||
mr: &merge_request::MergeRequest,
|
||||
) -> Option<(
|
||||
server_api::MergeRequestRefResponse,
|
||||
server_api::MergeRequestRefResponse,
|
||||
)> {
|
||||
if mr.state != merge_request::MergeRequestState::Merged {
|
||||
return None;
|
||||
}
|
||||
let result = mr.merged_result().ok();
|
||||
let diagnostic = mr
|
||||
.integration_approval()
|
||||
.err()
|
||||
.map(|error| MergeRequestRefDiagnostic {
|
||||
code: error.code().into(),
|
||||
message: "Stored integration evidence is missing, inconsistent, or revoked".into(),
|
||||
});
|
||||
let source = server_api::MergeRequestRefResponse {
|
||||
status: if diagnostic.is_none() {
|
||||
"known"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
.into(),
|
||||
revision_ref: result.map(|merge| merge.approved_source_ref.clone()),
|
||||
observed_at: result.map_or_else(
|
||||
|| mr.updated_at.to_rfc3339(),
|
||||
|merge| merge.created_at.to_rfc3339(),
|
||||
),
|
||||
diagnostic,
|
||||
};
|
||||
let target = server_api::MergeRequestRefResponse {
|
||||
status: if result.is_some() { "known" } else { "unknown" }.into(),
|
||||
revision_ref: result.map(|merge| merge.target_ref_after.clone()),
|
||||
observed_at: source.observed_at.clone(),
|
||||
diagnostic: result.is_none().then(|| MergeRequestRefDiagnostic {
|
||||
code: "merge_result_missing".into(),
|
||||
message: "Merged request has no stored result".into(),
|
||||
}),
|
||||
};
|
||||
Some((source, target))
|
||||
}
|
||||
|
||||
async fn scoped_show_merge_request(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||
@@ -15439,6 +15518,7 @@ async fn scoped_show_merge_request(
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
let store = merge_request_store(&api, &workspace_id)?;
|
||||
let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let integrated_refs = integrated_merge_refs(&mr);
|
||||
mr.thread = store.thread_page_by_id(
|
||||
&workspace_id,
|
||||
&merge_request_id,
|
||||
@@ -15453,6 +15533,9 @@ async fn scoped_show_merge_request(
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)?;
|
||||
let (source, target) = if let Some(refs) = integrated_refs {
|
||||
refs
|
||||
} else {
|
||||
let source = match (mr.selector_from.as_deref(), assignment.as_ref()) {
|
||||
(Some(selector), Some(assignment)) => {
|
||||
match observe_published_source_ref(
|
||||
@@ -15493,6 +15576,8 @@ async fn scoped_show_merge_request(
|
||||
"No current Coder Runtime is available to observe the target ref",
|
||||
),
|
||||
};
|
||||
(source, target)
|
||||
};
|
||||
let linked_tickets = mr
|
||||
.ticket_ids
|
||||
.iter()
|
||||
@@ -15527,7 +15612,16 @@ async fn scoped_merge_request_readiness(
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
|
||||
let (current_subject_ref, source_blocker) = match (mr.selector_from.as_deref(), assignment) {
|
||||
let (current_subject_ref, source_blocker) =
|
||||
if mr.state == merge_request::MergeRequestState::Merged {
|
||||
(
|
||||
mr.merged_result()
|
||||
.ok()
|
||||
.map(|merge| merge.approved_source_ref.clone()),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
match (mr.selector_from.as_deref(), assignment) {
|
||||
(Some(selector), Some(assignment)) => match observe_published_source_ref(
|
||||
&api,
|
||||
&workspace_id,
|
||||
@@ -15544,6 +15638,7 @@ async fn scoped_merge_request_readiness(
|
||||
},
|
||||
(Some(_), None) => (None, Some("source_ref_unavailable".to_string())),
|
||||
(None, _) => (None, None),
|
||||
}
|
||||
};
|
||||
let mut report = store.readiness(merge_request::ReadinessCheck {
|
||||
merge_request_id,
|
||||
@@ -32062,6 +32157,7 @@ mod tests {
|
||||
mod auth_logging_tests;
|
||||
mod subject_spawn_tests;
|
||||
mod subjektiv_jobs_tests;
|
||||
mod ticket_evidence_tests;
|
||||
mod value_profiles_tests;
|
||||
mod worker_operations_tests;
|
||||
include!("server_workspace_config_tests.rs");
|
||||
@@ -43337,6 +43433,28 @@ mod tests {
|
||||
.unwrap();
|
||||
let current_job = ticket_check_job(&api, &ticket_ref.id);
|
||||
let current_attempt = wait_for_ticket_check_attempt(&api, ¤t_job).await;
|
||||
// AwaitingResult can be persisted before Runtime input delivery finishes.
|
||||
// Observe both dispatches before attributing subsequent inputs to a result.
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let delivered = {
|
||||
let inputs = execution.inputs.lock().unwrap();
|
||||
[&stale_job.request.job_id, ¤t_job.request.job_id]
|
||||
.iter()
|
||||
.all(|id| {
|
||||
inputs
|
||||
.iter()
|
||||
.any(|(_, content)| content.contains(id.as_str()))
|
||||
})
|
||||
};
|
||||
if delivered {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("both checker inputs must be delivered before testing result notifications");
|
||||
execution.take_inputs();
|
||||
|
||||
api.accept_backend_job_result(
|
||||
@@ -43354,7 +43472,11 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
let unexpected = execution.take_inputs();
|
||||
assert!(
|
||||
unexpected.is_empty(),
|
||||
"unexpected inputs after stale result: {unexpected:?}"
|
||||
);
|
||||
let stale_delivery_id =
|
||||
crate::backend_job::delivery_id(&stale_job.request.job_id, &stale_attempt.attempt_id);
|
||||
let (stale_delivery, claimed) = api
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
// Included as a child of server::tests so the existing router/fixture helpers
|
||||
// remain private. These are API regressions, not repository/provider tests:
|
||||
// every authority record lives in a temporary database, with no Git checkout.
|
||||
use super::*;
|
||||
|
||||
use merge_request::{
|
||||
CompleteMergeRequest, CompleteTicket, ConflictResolution, MergeRequestAuth,
|
||||
MergeRequestReviewSubject, MergeRequestStore, MergeStrategy, OpenMergeRequest,
|
||||
RegisterReviewerChildSession, RequestMergeRequestReview, ReviewDecision, ReviewEvent,
|
||||
SubmitMergeRequestReview,
|
||||
};
|
||||
use rusqlite::{Connection, params};
|
||||
use server_api::{
|
||||
MergeRequestDetailResponse, MergeRequestListResponse, TicketDetail, TicketQueryResponse,
|
||||
TicketSourceRefObservation,
|
||||
};
|
||||
|
||||
const EVIDENCE_MR: &str = "ticket-evidence-mr";
|
||||
const EVIDENCE_SOURCE: &str = "immutable-approved-source";
|
||||
const EVIDENCE_TARGET: &str = "immutable-target-result";
|
||||
|
||||
// Deliberately freeze review dates: freshness must follow the exact item
|
||||
// revision and source/result snapshot, not a comparison of wall-clock dates.
|
||||
fn evidence_time() -> chrono::DateTime<Utc> {
|
||||
"2000-01-01T00:00:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
struct EvidenceApiFixture {
|
||||
_temp: tempfile::TempDir,
|
||||
api: WorkspaceApi,
|
||||
app: Router,
|
||||
backend: SqliteTicketBackend,
|
||||
store: MergeRequestStore,
|
||||
ticket_id: String,
|
||||
auth: MergeRequestAuth,
|
||||
}
|
||||
|
||||
impl EvidenceApiFixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api_with_remote_repository(temp.path()).await;
|
||||
assert!(
|
||||
api.config
|
||||
.repositories
|
||||
.iter()
|
||||
.all(|repository| repository.path.is_none())
|
||||
);
|
||||
assert!(!temp.path().join(".git").exists());
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("T716 remote immutable evidence");
|
||||
input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||
input.targets = vec![ticket::TicketTarget {
|
||||
repository_key: "test-repository".into(),
|
||||
ref_selector: Some("develop".into()),
|
||||
access: TicketTargetAccess::ReadWrite,
|
||||
}];
|
||||
let ticket_id = backend.create(input).unwrap().id;
|
||||
// Registry/assignment authority only; there is no live Worker or Runtime
|
||||
// capable of supplying an observation for this fixture.
|
||||
let worker = RuntimeWorkerRef::new("evidence-offline-runtime", "evidence-coder");
|
||||
api.store
|
||||
.upsert_worker_registry(&WorkerRegistryRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
worker: worker.clone(),
|
||||
display_name: "Offline evidence Coder".into(),
|
||||
profile: Some("builtin:coder".into()),
|
||||
retention_state: "normal".into(),
|
||||
transcript_ref: None,
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: evidence_time().to_rfc3339(),
|
||||
updated_at: evidence_time().to_rfc3339(),
|
||||
})
|
||||
.unwrap();
|
||||
let assignment_id = "evidence-assignment".to_string();
|
||||
api.store
|
||||
.set_current_ticket_coder_assignment(
|
||||
&TicketCoderAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket_id.clone(),
|
||||
assignment_id: assignment_id.clone(),
|
||||
worker: worker.clone(),
|
||||
assigned_by: "fixture".into(),
|
||||
assigned_at: evidence_time().to_rfc3339(),
|
||||
},
|
||||
None,
|
||||
"evidence-assigned",
|
||||
"evidence-assignment-operation",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let auth = MergeRequestAuth {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
repository_id: test_repository_id(&api),
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
assignment_id,
|
||||
};
|
||||
let store = merge_request_store(&api, TEST_WORKSPACE_ID).unwrap();
|
||||
store
|
||||
.open_merge_request(OpenMergeRequest {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
ticket_id: ticket_id.clone(),
|
||||
repository_id: auth.repository_id.clone(),
|
||||
selector_from: "work/t716".into(),
|
||||
selector_to: "develop".into(),
|
||||
summary: String::new(),
|
||||
auth: auth.clone(),
|
||||
now: evidence_time(),
|
||||
})
|
||||
.unwrap();
|
||||
let app = build_inner_router(api.clone());
|
||||
Self {
|
||||
_temp: temp,
|
||||
api,
|
||||
app,
|
||||
backend,
|
||||
store,
|
||||
ticket_id,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(&self) -> String {
|
||||
ticket_item_checker::item_revision(
|
||||
&self.backend.show(self.ticket_id.clone().into()).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn subjects(&self) -> Vec<MergeRequestReviewSubject> {
|
||||
vec![MergeRequestReviewSubject {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
subject_ref: EVIDENCE_SOURCE.into(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn approve(&self, token: &str) -> ReviewEvent {
|
||||
let child_session_id = format!("evidence-reviewer-{token}");
|
||||
self.store
|
||||
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
parent_runtime_id: self.auth.runtime_id.clone(),
|
||||
parent_worker_id: self.auth.worker_id.clone(),
|
||||
child_session_id: child_session_id.clone(),
|
||||
reviewer_profile: "builtin:reviewer".into(),
|
||||
now: evidence_time(),
|
||||
})
|
||||
.unwrap();
|
||||
self.store
|
||||
.request_review(RequestMergeRequestReview {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
ticket_item_revision: self.revision(),
|
||||
ticket_merge_request_subjects: self.subjects(),
|
||||
subject_ref: EVIDENCE_SOURCE.into(),
|
||||
child_session_id,
|
||||
capability_token: token.into(),
|
||||
auth: self.auth.clone(),
|
||||
now: evidence_time(),
|
||||
})
|
||||
.unwrap();
|
||||
self.store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
current_subject_ref: EVIDENCE_SOURCE.into(),
|
||||
capability_token: token.into(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: format!("Requirement approval {token}"),
|
||||
findings: vec![],
|
||||
now: evidence_time(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn integrate(&self, approval: &ReviewEvent) -> merge_request::MergeEvent {
|
||||
self.store
|
||||
.complete(CompleteMergeRequest {
|
||||
merge_request_id: EVIDENCE_MR.into(),
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
operation_id: "evidence-integration".into(),
|
||||
approval_event_id: approval.event_id.clone(),
|
||||
current_subject_ref: EVIDENCE_SOURCE.into(),
|
||||
target_ref_before: "target-before".into(),
|
||||
target_ref_after: EVIDENCE_TARGET.into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: self.auth.clone(),
|
||||
now: evidence_time(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn completion(&self, approval: &ReviewEvent) -> CompleteTicket {
|
||||
CompleteTicket {
|
||||
ticket_id: self.ticket_id.clone(),
|
||||
operation_id: "evidence-ticket-completion".into(),
|
||||
item_revision: self.revision(),
|
||||
merge_request_ids: vec![EVIDENCE_MR.into()],
|
||||
requirement_approval_event_id: approval.event_id.clone(),
|
||||
auth: self.auth.clone(),
|
||||
now: evidence_time(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn show(&self) -> TicketDetail {
|
||||
serde_json::from_value(
|
||||
get_json(
|
||||
self.app.clone(),
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", self.ticket_id),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn query(&self, filter: Value) -> TicketQueryResponse {
|
||||
serde_json::from_value(
|
||||
request_json(
|
||||
self.app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"),
|
||||
Some(filter),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn assert_filters(&self, approved: bool, missing: bool, stale: bool) {
|
||||
for (filter, matches) in [
|
||||
(json!({"evidence": ["approved_review"]}), approved),
|
||||
(json!({"attention": ["missing_evidence"]}), missing),
|
||||
(json!({"attention": ["stale_after_rescope"]}), stale),
|
||||
] {
|
||||
let result = self.query(filter.clone()).await;
|
||||
let ids = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let expected = if matches {
|
||||
vec![self.ticket_id.as_str()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
assert_eq!(ids, expected, "{filter}");
|
||||
if matches {
|
||||
let shown = self.show().await;
|
||||
assert_eq!(result.items[0].item_revision, shown.item_revision);
|
||||
assert_eq!(result.items[0].evidence, shown.evidence);
|
||||
assert_eq!(result.items[0].merge_requests, shown.merge_requests);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self) -> MergeRequestListResponse {
|
||||
serde_json::from_value(
|
||||
get_json(
|
||||
self.app.clone(),
|
||||
&format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/merge-requests?ticket_ref={}",
|
||||
self.ticket_id,
|
||||
),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn detail(&self, after: u64, limit: usize) -> MergeRequestDetailResponse {
|
||||
serde_json::from_value(
|
||||
get_json(
|
||||
self.app.clone(),
|
||||
&format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/merge-requests/{EVIDENCE_MR}?after={after}&limit={limit}",
|
||||
),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn rescope(&self) {
|
||||
self.backend
|
||||
.edit_item(
|
||||
self.ticket_id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(MarkdownText::new(
|
||||
"Revised requirements require a fresh exact-revision approval.",
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let approval = fixture.approve("integration");
|
||||
let merge = fixture.integrate(&approval);
|
||||
let revision = fixture.revision();
|
||||
let completion = fixture
|
||||
.store
|
||||
.complete_ticket(fixture.completion(&approval))
|
||||
.unwrap();
|
||||
assert_eq!(completion.item_revision, revision);
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let shown = fixture.show().await;
|
||||
assert_eq!(shown.state, "done");
|
||||
assert_eq!(shown.item_revision, revision);
|
||||
assert!(
|
||||
shown.evidence.complete_for_integration,
|
||||
"{:?}",
|
||||
shown.evidence.missing
|
||||
);
|
||||
assert!(shown.evidence.review_after_rescope);
|
||||
assert!(shown.evidence.missing.is_empty());
|
||||
assert_eq!(shown.merge_requests.len(), 1);
|
||||
let summary = &shown.merge_requests[0];
|
||||
assert_eq!(
|
||||
summary.current_subject_ref.as_deref(),
|
||||
Some(EVIDENCE_SOURCE)
|
||||
);
|
||||
assert_eq!(summary.review_subject_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(summary.review_status, "approved");
|
||||
assert_eq!(
|
||||
summary.source_ref_observation,
|
||||
TicketSourceRefObservation::NotRequired {}
|
||||
);
|
||||
assert_eq!(summary.integration_evidence_error, None);
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
|
||||
let listed = fixture.list().await;
|
||||
assert_eq!(listed.items.len(), 1);
|
||||
assert_eq!(&listed.items[0].summary, summary);
|
||||
assert!(listed.items[0].ref_diagnostics.is_empty());
|
||||
assert_eq!(listed.items[0].thread_event_count, merge.sequence as usize);
|
||||
// Neither page includes the MergeEvent. Ref evidence must be computed from
|
||||
// the full stored thread before applying response-thread pagination.
|
||||
let first = fixture.detail(0, 1).await;
|
||||
assert_eq!(first.merge_request.thread.len(), 1);
|
||||
assert!(matches!(
|
||||
first.merge_request.thread[0],
|
||||
server_api::MergeRequestThreadEvent::ReviewRequested(_)
|
||||
));
|
||||
let empty = fixture.detail(merge.sequence, 1).await;
|
||||
assert!(empty.merge_request.thread.is_empty());
|
||||
for detail in [first, empty] {
|
||||
assert_eq!(detail.source.status, "known");
|
||||
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.target.status, "known");
|
||||
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
assert_eq!(detail.source.observed_at, merge.created_at.to_rfc3339());
|
||||
assert_eq!(detail.source.diagnostic, None);
|
||||
assert_eq!(detail.target.diagnostic, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_result_snapshot() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let integration = fixture.approve("integration");
|
||||
let merge = fixture.integrate(&integration);
|
||||
fixture.rescope();
|
||||
let revision = fixture.revision();
|
||||
assert_ne!(integration.ticket_item_revision, revision);
|
||||
|
||||
let stale = fixture.show().await;
|
||||
assert!(stale.evidence.approved_current_subject); // integration proof survives
|
||||
assert!(!stale.evidence.review_after_rescope);
|
||||
assert!(!stale.evidence.complete_for_integration);
|
||||
assert!(
|
||||
stale
|
||||
.evidence
|
||||
.missing
|
||||
.contains(&"review_after_rescope".into())
|
||||
);
|
||||
fixture.assert_filters(false, true, true).await;
|
||||
let mut outdated_completion = fixture.completion(&integration);
|
||||
outdated_completion.item_revision = integration.ticket_item_revision.clone();
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(outdated_completion),
|
||||
Err(merge_request::MergeRequestError::Conflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(fixture.completion(&integration)),
|
||||
Err(merge_request::MergeRequestError::NotReady(message))
|
||||
if message == merge_request::MergeRequestEvidenceError::ItemRevisionMismatch.as_str()
|
||||
));
|
||||
assert_eq!(fixture.show().await.state, "inprogress");
|
||||
|
||||
let latest = fixture.approve("latest-revision");
|
||||
assert_eq!(latest.created_at, integration.created_at);
|
||||
assert_eq!(latest.ticket_item_revision, revision);
|
||||
assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects());
|
||||
let fresh = fixture.show().await;
|
||||
assert!(
|
||||
fresh.evidence.complete_for_integration,
|
||||
"{:?}",
|
||||
fresh.evidence.missing
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.merge_requests[0].review_excerpt.as_deref(),
|
||||
Some("Requirement approval integration")
|
||||
);
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(fixture.completion(&integration)),
|
||||
Err(merge_request::MergeRequestError::NotReady(message))
|
||||
if message == merge_request::MergeRequestEvidenceError::ApprovalNotEffective.as_str()
|
||||
));
|
||||
// A current revision alone is insufficient: simulate a stored attestation
|
||||
// against a different result set using typed serialized payloads in this
|
||||
// temporary fixture database, never a live Workspace database.
|
||||
let mut wrong_snapshot = latest.clone();
|
||||
wrong_snapshot
|
||||
.ticket_merge_request_subjects
|
||||
.push(MergeRequestReviewSubject {
|
||||
merge_request_id: "other-result".into(),
|
||||
subject_ref: "other-approved-source".into(),
|
||||
});
|
||||
let mut requested = fixture
|
||||
.store
|
||||
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
|
||||
.unwrap()
|
||||
.thread
|
||||
.into_iter()
|
||||
.find_map(|event| match event {
|
||||
merge_request::MergeRequestThreadEvent::ReviewRequested(event)
|
||||
if event.event_id == latest.request_event_id =>
|
||||
{
|
||||
Some(event)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
requested.ticket_merge_request_subjects = wrong_snapshot.ticket_merge_request_subjects.clone();
|
||||
let connection = Connection::open(&fixture.api.config.database_path).unwrap();
|
||||
for (event_id, payload) in [
|
||||
(
|
||||
&requested.event_id,
|
||||
serde_json::to_string(&requested).unwrap(),
|
||||
),
|
||||
(
|
||||
&wrong_snapshot.event_id,
|
||||
serde_json::to_string(&wrong_snapshot).unwrap(),
|
||||
),
|
||||
] {
|
||||
assert_eq!(connection.execute(
|
||||
"UPDATE merge_request_thread_events SET payload_json=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3",
|
||||
params![TEST_WORKSPACE_ID, EVIDENCE_MR, event_id, payload],
|
||||
).unwrap(), 1);
|
||||
}
|
||||
drop(connection);
|
||||
let wrong = fixture.show().await;
|
||||
assert!(wrong.evidence.approved_current_subject);
|
||||
assert!(!wrong.evidence.complete_for_integration);
|
||||
fixture.assert_filters(false, true, true).await;
|
||||
assert!(matches!(
|
||||
fixture.store.complete_ticket(fixture.completion(&latest)),
|
||||
Err(merge_request::MergeRequestError::NotReady(message))
|
||||
if message == merge_request::MergeRequestEvidenceError::SourceSnapshotMismatch.as_str()
|
||||
));
|
||||
assert_eq!(fixture.show().await.state, "inprogress");
|
||||
|
||||
let repaired = fixture.approve("exact-snapshot");
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
// The newest requirement approval must not replace the historical approval
|
||||
// and source which actually authorized integration.
|
||||
let stored = fixture
|
||||
.store
|
||||
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored.integration_approval().unwrap().event_id,
|
||||
integration.event_id
|
||||
);
|
||||
assert_eq!(stored.merged_result().unwrap(), &merge);
|
||||
let detail = fixture.detail(repaired.sequence - 1, 1).await;
|
||||
assert_eq!(detail.merge_request.thread.len(), 1);
|
||||
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
|
||||
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
|
||||
let completion = fixture
|
||||
.store
|
||||
.complete_ticket(fixture.completion(&repaired))
|
||||
.unwrap();
|
||||
assert_eq!(completion.item_revision, revision);
|
||||
assert_eq!(completion.requirement_approval_event_id, repaired.event_id);
|
||||
assert_eq!(fixture.show().await.state, "done");
|
||||
fixture.assert_filters(true, false, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescope() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let approval = fixture.approve("open-review");
|
||||
fixture.rescope();
|
||||
// Simulate a retired Coder assignment, leaving an open MR and its review
|
||||
// history. Only this temporary authority fixture is changed.
|
||||
let connection = Connection::open(&fixture.api.config.database_path).unwrap();
|
||||
assert_eq!(connection.execute(
|
||||
"DELETE FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![TEST_WORKSPACE_ID, fixture.ticket_id],
|
||||
).unwrap(), 1);
|
||||
drop(connection);
|
||||
|
||||
let shown = fixture.show().await;
|
||||
let summary = &shown.merge_requests[0];
|
||||
assert_eq!(summary.state, "open");
|
||||
assert_eq!(summary.current_subject_ref, None);
|
||||
assert_eq!(
|
||||
summary.source_ref_observation,
|
||||
TicketSourceRefObservation::Unavailable {
|
||||
code: "source_ref_runtime_unavailable".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(shown.evidence.review_status.as_deref(), Some("unknown"));
|
||||
assert!(!shown.evidence.complete_for_integration);
|
||||
assert!(
|
||||
shown
|
||||
.evidence
|
||||
.missing
|
||||
.contains(&"source_ref_unavailable".into())
|
||||
);
|
||||
assert!(
|
||||
!shown
|
||||
.evidence
|
||||
.missing
|
||||
.contains(&"review_after_rescope".into())
|
||||
);
|
||||
fixture.assert_filters(false, true, false).await;
|
||||
let listed = fixture.list().await;
|
||||
assert_eq!(listed.items.len(), 1);
|
||||
assert_eq!(&listed.items[0].summary, summary);
|
||||
assert_eq!(
|
||||
listed.items[0].ref_diagnostics[0].code,
|
||||
"source_ref_runtime_unavailable"
|
||||
);
|
||||
let detail = fixture.detail(approval.sequence, 1).await;
|
||||
assert!(detail.merge_request.thread.is_empty());
|
||||
assert_eq!(detail.source.status, "unknown");
|
||||
assert_eq!(detail.source.revision_ref, None);
|
||||
assert_eq!(
|
||||
detail.source.diagnostic.unwrap().code,
|
||||
"source_ref_runtime_unavailable"
|
||||
);
|
||||
assert_eq!(detail.target.status, "unknown");
|
||||
assert_eq!(
|
||||
detail.target.diagnostic.unwrap().code,
|
||||
"target_ref_runtime_unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evidence_query_continues_bounded_scans_past_nonmatching_tickets() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let approval = fixture.approve("integration");
|
||||
fixture.integrate(&approval);
|
||||
for title in [
|
||||
"A nonmatching one",
|
||||
"B nonmatching two",
|
||||
"C nonmatching three",
|
||||
] {
|
||||
fixture
|
||||
.backend
|
||||
.create(ticket::NewTicket::new(title))
|
||||
.unwrap();
|
||||
}
|
||||
for filter in [
|
||||
json!({"evidence": ["approved_review"]}),
|
||||
json!({"review_status": "approved"}),
|
||||
] {
|
||||
let mut request = filter.clone();
|
||||
request["sort"] = json!("title");
|
||||
request["limit"] = json!(1);
|
||||
let first = fixture.query(request.clone()).await;
|
||||
assert!(first.items.is_empty());
|
||||
assert!(first.page.has_more);
|
||||
assert!(first.page.source_truncated);
|
||||
request["cursor"] = json!(first.page.next_cursor.unwrap());
|
||||
let second = fixture.query(request).await;
|
||||
assert_eq!(second.items.len(), 1);
|
||||
assert_eq!(second.items[0].id, fixture.ticket_id);
|
||||
assert!(second.items[0].evidence.review_after_rescope);
|
||||
assert!(!second.page.has_more);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_observation_diagnostics_preserve_provider_failure_codes() {
|
||||
for (provider_code, expected) in [
|
||||
("repository_ref_not_found", "source_ref_not_found"),
|
||||
(
|
||||
"repository_ref_provider_timeout",
|
||||
"source_ref_provider_timeout",
|
||||
),
|
||||
(
|
||||
"repository_ref_provider_unavailable",
|
||||
"source_ref_provider_unavailable",
|
||||
),
|
||||
(
|
||||
"repository_access_credential_expired",
|
||||
"source_ref_credential_expired",
|
||||
),
|
||||
] {
|
||||
let error = Error::RuntimeOperationFailed {
|
||||
runtime_id: "fixture-runtime".into(),
|
||||
code: provider_code.into(),
|
||||
message: "Provider observation failed".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
merge_ref_diagnostic(remap_source_ref_error(error.into())).code,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Ticket evidence and immutable Merge Request results
|
||||
|
||||
Ticket lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded `done` state or a `CompleteTicket` event.
|
||||
|
||||
## Evidence subjects
|
||||
|
||||
- An open MR uses the Repository/Runtime authority's provider-resolved current source. No Server-local checkout is assumed. Failure is a tagged `source_ref_observation: { status: "unavailable", code }`, not proof that review is stale. Ticket review status is `unknown`; `missing` includes `source_ref_unavailable`, not `review_after_rescope` merely because observation failed.
|
||||
- A merged MR uses its stored MergeResult's `approved_source_ref` and `approval_event_id`. Branch deletion/movement, checkout absence, and provider outage do not trigger source observation. The summary's `current_subject_ref` is the immutable merged source and observation is `not_required`.
|
||||
- Integration approval must exist, approve that exact source, predate the merge, have matching ReviewRequested evidence, and not have been revoked. `integration_evidence_error` is a bounded machine code for invalid stored evidence. `merged` alone never creates approval.
|
||||
|
||||
A merged MR's review status/timestamps describe **integration approval**, not a later requirement approval. Paged MR detail reads evaluate the full stored result before paging; UI must not infer approval from a merge event appearing in one page.
|
||||
|
||||
## Current requirements
|
||||
|
||||
`merge_request::requirement_approval` is shared by CompleteTicket and Ticket reads. It validates an effective Reviewer approval of the current item revision and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest a new revision/snapshot without replacing MergeResult or its integration approval.
|
||||
|
||||
`approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`.
|
||||
|
||||
`complete_for_integration` means the summary has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. CompleteTicket additionally requires all linked results merged and validates explicit approval identity, lifecycle, and assignment transactionally.
|
||||
|
||||
Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used.
|
||||
|
||||
Query candidate SQL narrows Ticket metadata, not live evidence. Evidence predicates are evaluated from the same full MR projection as ShowTicket, after source observation. Bounded scans can return an empty page with `has_more` and a cursor after the last scanned candidate; callers must follow that cursor rather than treat an empty page as exhaustion. This prevents stale SQL/report heuristics or early nonmatching candidates from hiding later evidence matches.
|
||||
@@ -0,0 +1,7 @@
|
||||
# SubWorker cwd changes logical tool path interpretation
|
||||
|
||||
During T-716 delegation, a child spawned with `cwd: crates/merge-request` and explicit read grants for `docs/development/rust-testing-strategy.md` could not read that path. `Read` was presented as Workdir-root-relative, but the observed error resolved it as `crates/merge-request/docs/development/rust-testing-strategy.md`. The same prefixing occurred for `crates/merge-request/src/lib.rs`; parent traversal was rejected.
|
||||
|
||||
The child made no edits. Rebinding a child at the checkout root with read-only `.` plus writable `crates/merge-request` let it read the required strategy and edit only the delegated crate. No denied write was bypassed.
|
||||
|
||||
Suggested improvement: make logical path resolution and child cwd semantics consistent in the tool description, capability checks, and execution. If cwd affects filesystem tools, expose the effective logical root explicitly and retain an unambiguous way to address other granted read paths. Scope validation should reject unusable grants at spawn rather than leave the child discovering path-prefix behavior.
|
||||
+59
-2
@@ -11221,6 +11221,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"integration_evidence_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"merge_request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -11263,6 +11269,9 @@
|
||||
"selector_to": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_ref_observation": {
|
||||
"$ref": "#/components/schemas/TicketSourceRefObservation"
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -11276,7 +11285,8 @@
|
||||
"state",
|
||||
"review_status",
|
||||
"selector_to",
|
||||
"updated_at"
|
||||
"updated_at",
|
||||
"source_ref_observation"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -12069,6 +12079,53 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TicketSourceRefObservation": {
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "observed",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"const": "unavailable",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"code"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "not_required",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"TicketStateChange": {
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -15481,7 +15538,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf"
|
||||
"x-yoi-source-digest": "sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
@@ -117,7 +117,7 @@ export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>;
|
||||
|
||||
export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; };
|
||||
|
||||
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; state: string; updated_at: string; };
|
||||
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
|
||||
|
||||
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
|
||||
|
||||
@@ -131,6 +131,8 @@ export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: str
|
||||
|
||||
export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; };
|
||||
|
||||
export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; };
|
||||
|
||||
export type TicketStatus = "Open" | "Closed";
|
||||
|
||||
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -4,6 +4,7 @@ declare const Deno: {
|
||||
|
||||
import {
|
||||
parseMergeRequestDetailResponse,
|
||||
parseMergeRequestListResponse,
|
||||
parseObjectiveListResponse,
|
||||
parseTicketDetail,
|
||||
parseTicketListResponse,
|
||||
@@ -80,8 +81,8 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits"
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
|
||||
const fixture = {
|
||||
function ticketDetailFixture() {
|
||||
return {
|
||||
action_eligibility: {
|
||||
blockers: [],
|
||||
can_assign_orchestrator: false,
|
||||
@@ -142,7 +143,10 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular
|
||||
],
|
||||
title: "Ticket",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
|
||||
const fixture = ticketDetailFixture();
|
||||
const parsed = parseTicketDetail(fixture);
|
||||
assertEquals(parsed.targets.length, 2);
|
||||
assertEquals(parsed.targets[0]?.repository_key, "docs");
|
||||
@@ -168,6 +172,157 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular
|
||||
);
|
||||
});
|
||||
|
||||
const mergeRequestSummary = {
|
||||
merge_request_id: "mr-1",
|
||||
repository_key: "main",
|
||||
state: "open",
|
||||
selector_from: "work/T-1",
|
||||
selector_to: "develop",
|
||||
updated_at: "2026-10-08T00:00:00Z",
|
||||
current_subject_ref: "source-1",
|
||||
source_ref_observation: { status: "observed" },
|
||||
integration_evidence_error: null,
|
||||
review_status: "approved",
|
||||
review_subject_ref: "source-1",
|
||||
review_requested_at: null,
|
||||
review_submitted_at: null,
|
||||
review_excerpt: null,
|
||||
};
|
||||
|
||||
function mergeRequestListFixture(summary: unknown) {
|
||||
return {
|
||||
items: [{ summary, ticket_ids: ["ticket-id"], thread_event_count: 2 }],
|
||||
next_cursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Merge Request summaries preserve live observation and immutable merged evidence separately", () => {
|
||||
for (
|
||||
const summary of [
|
||||
mergeRequestSummary,
|
||||
{
|
||||
...mergeRequestSummary,
|
||||
current_subject_ref: null,
|
||||
source_ref_observation: {
|
||||
status: "unavailable",
|
||||
code: "repository_unavailable",
|
||||
},
|
||||
review_status: "pending",
|
||||
},
|
||||
{
|
||||
...mergeRequestSummary,
|
||||
state: "merged",
|
||||
source_ref_observation: { status: "not_required" },
|
||||
},
|
||||
{
|
||||
...mergeRequestSummary,
|
||||
state: "merged",
|
||||
source_ref_observation: { status: "not_required" },
|
||||
integration_evidence_error: "approval_event_missing",
|
||||
review_status: "none",
|
||||
},
|
||||
]
|
||||
) {
|
||||
const listed = parseMergeRequestListResponse(
|
||||
mergeRequestListFixture(summary),
|
||||
);
|
||||
const detailed = parseTicketDetail({
|
||||
...ticketDetailFixture(),
|
||||
merge_requests: [summary],
|
||||
merge_request: summary,
|
||||
});
|
||||
for (
|
||||
const parsed of [
|
||||
listed.items[0]?.summary,
|
||||
detailed.merge_requests[0],
|
||||
detailed.merge_request,
|
||||
]
|
||||
) {
|
||||
assertEquals(
|
||||
parsed?.source_ref_observation,
|
||||
summary.source_ref_observation,
|
||||
);
|
||||
assertEquals(parsed?.current_subject_ref, summary.current_subject_ref);
|
||||
assertEquals(
|
||||
parsed?.integration_evidence_error,
|
||||
summary.integration_evidence_error,
|
||||
);
|
||||
assertEquals(parsed?.review_subject_ref, summary.review_subject_ref);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Merge Request summaries reject missing and malformed tagged source observations", () => {
|
||||
for (
|
||||
const [observation, message] of [
|
||||
[undefined, "source_ref_observation must be an object"],
|
||||
[null, "source_ref_observation must be an object"],
|
||||
["observed", "source_ref_observation must be an object"],
|
||||
[{ status: "unknown" }, "status is invalid"],
|
||||
[{ status: "unavailable" }, "code must be a string"],
|
||||
[{ status: "unavailable", code: 42 }, "code must be a string"],
|
||||
[
|
||||
{ status: "unavailable", code: "x".repeat(262_145) },
|
||||
"code exceeds its length limit",
|
||||
],
|
||||
[{ status: "observed", code: "stale" }, "unknown field code"],
|
||||
[{ status: "not_required", code: "stale" }, "unknown field code"],
|
||||
[
|
||||
{ status: "unavailable", code: "missing", extra: true },
|
||||
"unknown field extra",
|
||||
],
|
||||
] as const
|
||||
) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseMergeRequestListResponse(mergeRequestListFixture({
|
||||
...mergeRequestSummary,
|
||||
source_ref_observation: observation,
|
||||
})),
|
||||
message,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Merge Request summaries require a nullable bounded integration evidence error", () => {
|
||||
for (
|
||||
const [error, message] of [
|
||||
[undefined, "integration_evidence_error must be a string"],
|
||||
[false, "integration_evidence_error must be a string"],
|
||||
[
|
||||
"x".repeat(262_145),
|
||||
"integration_evidence_error exceeds its length limit",
|
||||
],
|
||||
] as const
|
||||
) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseMergeRequestListResponse(mergeRequestListFixture({
|
||||
...mergeRequestSummary,
|
||||
integration_evidence_error: error,
|
||||
})),
|
||||
message,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => {
|
||||
const fixture = ticketDetailFixture();
|
||||
const parsed = parseTicketDetail({
|
||||
...fixture,
|
||||
state: "done",
|
||||
evidence: { ...fixture.evidence, missing: ["review_after_rescope"] },
|
||||
merge_requests: [{
|
||||
...mergeRequestSummary,
|
||||
state: "merged",
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}],
|
||||
});
|
||||
assertEquals(parsed.state, "done");
|
||||
assertEquals(parsed.evidence.complete_for_integration, false);
|
||||
assertEquals(parsed.merge_requests[0]?.review_status, "approved");
|
||||
});
|
||||
|
||||
Deno.test("Ticket Browser parser validates Ticket create responses", () => {
|
||||
const parsed = parseTicketRecordRef({
|
||||
id: "ticket-id",
|
||||
|
||||
@@ -446,6 +446,23 @@ function parseActionEligibility(
|
||||
};
|
||||
}
|
||||
|
||||
function parseSourceRefObservation(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketMergeRequestSummary["source_ref_observation"] {
|
||||
const item = object(value, label);
|
||||
const status = string(item.status, `${label}.status`);
|
||||
if (status === "observed" || status === "not_required") {
|
||||
exact(item, ["status"], label);
|
||||
return { status };
|
||||
}
|
||||
if (status === "unavailable") {
|
||||
exact(item, ["status", "code"], label);
|
||||
return { status, code: string(item.code, `${label}.code`) };
|
||||
}
|
||||
throw new Error(`${label}.status is invalid`);
|
||||
}
|
||||
|
||||
function parseTicketMergeRequestSummary(
|
||||
value: unknown,
|
||||
label: string,
|
||||
@@ -453,6 +470,8 @@ function parseTicketMergeRequestSummary(
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"current_subject_ref",
|
||||
"integration_evidence_error",
|
||||
"source_ref_observation",
|
||||
"merge_request_id",
|
||||
"repository_key",
|
||||
"review_excerpt",
|
||||
@@ -470,6 +489,14 @@ function parseTicketMergeRequestSummary(
|
||||
item.current_subject_ref,
|
||||
`${label}.current_subject_ref`,
|
||||
),
|
||||
integration_evidence_error: nullableString(
|
||||
item.integration_evidence_error,
|
||||
`${label}.integration_evidence_error`,
|
||||
),
|
||||
source_ref_observation: parseSourceRefObservation(
|
||||
item.source_ref_observation,
|
||||
`${label}.source_ref_observation`,
|
||||
),
|
||||
merge_request_id: string(
|
||||
item.merge_request_id,
|
||||
`${label}.merge_request_id`,
|
||||
|
||||
@@ -181,6 +181,8 @@ export function dashboardFixture(
|
||||
review_requested_at: updated(10),
|
||||
review_submitted_at: null,
|
||||
current_subject_ref: "abc123",
|
||||
source_ref_observation: { status: "observed" },
|
||||
integration_evidence_error: null,
|
||||
review_subject_ref: "abc123",
|
||||
},
|
||||
thread_event_count: 1,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
readTextFile(path: URL): Promise<string>;
|
||||
};
|
||||
|
||||
import type { TicketMergeRequestSummary } from "#lib/generated/ticket-api.ts";
|
||||
import { fixtureDetail } from "./home/dashboard.test-fixtures.ts";
|
||||
import { parseTicketDetail } from "./api/ticket-browser.ts";
|
||||
import {
|
||||
sourceReviewFreshness,
|
||||
summarySourceReviewStatus,
|
||||
} from "./merge-request-status.ts";
|
||||
import type { MergeRequestDetail } from "./api/merge-requests.ts";
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, received ${
|
||||
JSON.stringify(actual)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const summary: TicketMergeRequestSummary = {
|
||||
merge_request_id: "mr-1",
|
||||
repository_key: "main",
|
||||
state: "open",
|
||||
selector_from: "work/T-1",
|
||||
selector_to: "develop",
|
||||
updated_at: "2026-10-08T00:00:00Z",
|
||||
current_subject_ref: "source-1",
|
||||
source_ref_observation: { status: "observed" },
|
||||
integration_evidence_error: null,
|
||||
review_status: "approved",
|
||||
review_subject_ref: "source-1",
|
||||
};
|
||||
|
||||
Deno.test("unavailable live observation shows its error rather than claiming review is stale or pending", () => {
|
||||
assertEquals(
|
||||
summarySourceReviewStatus({
|
||||
...summary,
|
||||
current_subject_ref: null,
|
||||
source_ref_observation: {
|
||||
status: "unavailable",
|
||||
code: "repository_unavailable",
|
||||
},
|
||||
review_status: "pending",
|
||||
}),
|
||||
"Live source observation unavailable: repository_unavailable.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("merged summary labels approval as immutable persisted evidence without live observation", () => {
|
||||
assertEquals(
|
||||
summarySourceReviewStatus({
|
||||
...summary,
|
||||
state: "merged",
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}),
|
||||
"Integration approval recorded for immutable merged source source-1.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("merged evidence error does not claim a valid integration approval", () => {
|
||||
assertEquals(
|
||||
summarySourceReviewStatus({
|
||||
...summary,
|
||||
state: "merged",
|
||||
source_ref_observation: { status: "not_required" },
|
||||
integration_evidence_error: "approval_event_missing",
|
||||
}),
|
||||
"Integration approval unavailable for immutable merged source source-1.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("observed live source movement requires fresh review but an unchanged ref retains approval", () => {
|
||||
assertEquals(
|
||||
summarySourceReviewStatus(summary),
|
||||
"Live source review: approved for exact ref source-1.",
|
||||
);
|
||||
assertEquals(
|
||||
summarySourceReviewStatus({ ...summary, current_subject_ref: "source-2" }),
|
||||
"Fresh live source review required: selector_from moved from source-1 to source-2.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
|
||||
const ticket = parseTicketDetail({
|
||||
...fixtureDetail(),
|
||||
state: "done",
|
||||
evidence: {
|
||||
...fixtureDetail().evidence,
|
||||
missing: ["review_after_rescope"],
|
||||
},
|
||||
merge_requests: [{
|
||||
...summary,
|
||||
state: "merged",
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}],
|
||||
});
|
||||
assertEquals(ticket.state, "done");
|
||||
assertEquals(ticket.evidence.complete_for_integration, false);
|
||||
assertEquals(
|
||||
summarySourceReviewStatus(ticket.merge_requests[0]!),
|
||||
"Integration approval recorded for immutable merged source source-1.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
for (
|
||||
const required of [
|
||||
"Current requirement evidence",
|
||||
"ticket.evidence.complete_for_integration",
|
||||
"ticket.evidence.approved_current_subject",
|
||||
"ticket.evidence.missing",
|
||||
"summarySourceReviewStatus(mergeRequest)",
|
||||
"Immutable integration evidence",
|
||||
"Integration evidence error:",
|
||||
'{#if ticket.state === "done"}',
|
||||
"Incomplete current requirement evidence does not cancel recorded completion.",
|
||||
]
|
||||
) {
|
||||
if (!source.includes(required)) {
|
||||
throw new Error(`Ticket route must include ${required}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => {
|
||||
const list = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../routes/w/[workspaceId]/merge-requests/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
assertEquals(list.includes("summarySourceReviewStatus(mergeRequest)"), true);
|
||||
assertEquals(list.includes("Integration evidence error:"), true);
|
||||
const detail = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
assertEquals(detail.includes("Recorded integration approval"), true);
|
||||
assertEquals(detail.includes("Live source revision"), true);
|
||||
assertEquals(detail.includes("Source observation error"), true);
|
||||
});
|
||||
|
||||
Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => {
|
||||
const detail: MergeRequestDetail = {
|
||||
merge_request_id: "mr-1",
|
||||
workspace_id: "workspace-a",
|
||||
repository_key: "main",
|
||||
ticket_ids: [],
|
||||
linked_tickets: [],
|
||||
selector_from: "work/T-1",
|
||||
selector_to: "develop",
|
||||
state: "merged",
|
||||
created_at: summary.updated_at,
|
||||
updated_at: summary.updated_at,
|
||||
source: {
|
||||
status: "known",
|
||||
ref: "source-1",
|
||||
observed_at: summary.updated_at,
|
||||
},
|
||||
target: {
|
||||
status: "known",
|
||||
ref: "target-2",
|
||||
observed_at: summary.updated_at,
|
||||
},
|
||||
thread: [{
|
||||
kind: "merge",
|
||||
event_id: "merge-1",
|
||||
sequence: 3,
|
||||
created_at: summary.updated_at,
|
||||
operation_id: "operation-1",
|
||||
approval_event_id: "approval-1",
|
||||
approved_source_ref: "source-1",
|
||||
target_ref_before: "target-1",
|
||||
target_ref_after: "target-2",
|
||||
strategy: "fast_forward",
|
||||
resolution: "none",
|
||||
merged_by: { runtime_id: "runtime-a", worker_id: "worker-a" },
|
||||
}],
|
||||
};
|
||||
assertEquals(
|
||||
sourceReviewFreshness(detail),
|
||||
"Recorded integration approval approval-1 for immutable merged source source-1.",
|
||||
);
|
||||
assertEquals(
|
||||
sourceReviewFreshness({ ...detail, thread: [] }),
|
||||
"Recorded integration approval for immutable merged source source-1.",
|
||||
);
|
||||
assertEquals(
|
||||
sourceReviewFreshness({
|
||||
...detail,
|
||||
source: {
|
||||
...detail.source,
|
||||
status: "unknown",
|
||||
diagnostic: { code: "approval_revoked", message: "Revoked" },
|
||||
},
|
||||
}),
|
||||
"Immutable integration evidence unavailable: approval_revoked.",
|
||||
);
|
||||
assertEquals(
|
||||
sourceReviewFreshness({
|
||||
...detail,
|
||||
source: { ...detail.source, status: "unknown", ref: null },
|
||||
}),
|
||||
"Immutable integration evidence unavailable: no validated persisted source.",
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TicketMergeRequestSummary } from "#lib/generated/ticket-api.ts";
|
||||
import type {
|
||||
MergeRequestDetail,
|
||||
MergeRequestThreadEvent,
|
||||
@@ -29,6 +30,24 @@ function requestHasTerminalOutcome(
|
||||
export function sourceReviewFreshness(
|
||||
mergeRequest: MergeRequestDetail,
|
||||
): string {
|
||||
if (mergeRequest.state === "merged") {
|
||||
// The Backend validates the full persisted result before paging the thread.
|
||||
// A merge event in a page alone is not evidence of a valid approval.
|
||||
if (mergeRequest.source.diagnostic) {
|
||||
return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`;
|
||||
}
|
||||
const source = mergeRequest.source.ref;
|
||||
if (mergeRequest.source.status !== "known" || !source) {
|
||||
return "Immutable integration evidence unavailable: no validated persisted source.";
|
||||
}
|
||||
const merge = [...mergeRequest.thread].reverse().find(
|
||||
(event): event is Extract<MergeRequestThreadEvent, { kind: "merge" }> =>
|
||||
event.kind === "merge",
|
||||
);
|
||||
return merge
|
||||
? `Recorded integration approval ${merge.approval_event_id} for immutable merged source ${source}.`
|
||||
: `Recorded integration approval for immutable merged source ${source}.`;
|
||||
}
|
||||
const source = mergeRequest.source.ref;
|
||||
if (!source) return "Source review unavailable: selector_from is unresolved.";
|
||||
|
||||
@@ -70,6 +89,41 @@ export function sourceReviewFreshness(
|
||||
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
||||
}
|
||||
|
||||
export function summarySourceReviewStatus(
|
||||
mergeRequest: TicketMergeRequestSummary,
|
||||
): string {
|
||||
const source = mergeRequest.current_subject_ref;
|
||||
if (mergeRequest.state === "merged") {
|
||||
if (!source) return "Immutable merged source unavailable.";
|
||||
if (
|
||||
mergeRequest.integration_evidence_error !== null ||
|
||||
mergeRequest.review_status !== "approved" ||
|
||||
mergeRequest.review_subject_ref !== source
|
||||
) {
|
||||
return `Integration approval unavailable for immutable merged source ${source}.`;
|
||||
}
|
||||
return `Integration approval recorded for immutable merged source ${source}.`;
|
||||
}
|
||||
|
||||
const observation = mergeRequest.source_ref_observation;
|
||||
if (observation.status === "unavailable") {
|
||||
return `Live source observation unavailable: ${observation.code}.`;
|
||||
}
|
||||
if (observation.status === "not_required") {
|
||||
return "Live source observation not required.";
|
||||
}
|
||||
if (!source) {
|
||||
return "Live source review unavailable: no source ref was observed.";
|
||||
}
|
||||
if (mergeRequest.review_subject_ref === source) {
|
||||
return `Live source review: ${mergeRequest.review_status} for exact ref ${source}.`;
|
||||
}
|
||||
if (mergeRequest.review_subject_ref) {
|
||||
return `Fresh live source review required: selector_from moved from ${mergeRequest.review_subject_ref} to ${source}.`;
|
||||
}
|
||||
return `Fresh live source review required: no effective verdict exists for ${source}.`;
|
||||
}
|
||||
|
||||
export function targetIntegrationStatus(
|
||||
mergeRequest: MergeRequestDetail,
|
||||
): string {
|
||||
|
||||
@@ -80,7 +80,16 @@ Deno.test("Merge Request UI separates source review freshness from target integr
|
||||
detailPage.includes("targetIntegrationStatus"),
|
||||
"MR detail page does not render authority status projections",
|
||||
);
|
||||
for (const source of [`${detailPage}\n${statusProjection}`, ticketPage]) {
|
||||
assert(
|
||||
ticketPage.includes("summarySourceReviewStatus(mergeRequest)"),
|
||||
"Ticket page does not render the source observation/integration evidence projection",
|
||||
);
|
||||
for (
|
||||
const source of [
|
||||
`${detailPage}\n${statusProjection}`,
|
||||
`${ticketPage}\n${statusProjection}`,
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes("Fresh source review required"),
|
||||
"missing source-review freshness diagnostic",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||
import { summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -40,8 +41,12 @@
|
||||
</strong>
|
||||
</div>
|
||||
<p class="ticket-summary">
|
||||
Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status}
|
||||
Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p class="ticket-summary">{summarySourceReviewStatus(mergeRequest)}</p>
|
||||
{#if mergeRequest.integration_evidence_error !== null}
|
||||
<p class="workspace-callout is-error">Integration evidence error: {mergeRequest.integration_evidence_error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ticket-meta">
|
||||
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
|
||||
|
||||
+5
-2
@@ -59,7 +59,10 @@
|
||||
<div><dt>Repository</dt><dd>{mergeRequest.repository_key}</dd></div>
|
||||
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
|
||||
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
|
||||
<div><dt>Source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
||||
<div><dt>Live source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
||||
{#if mergeRequest.source.diagnostic}
|
||||
<div><dt>Source observation error</dt><dd>{mergeRequest.source.diagnostic.code}: {mergeRequest.source.diagnostic.message}</dd></div>
|
||||
{/if}
|
||||
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
|
||||
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
|
||||
@@ -69,7 +72,7 @@
|
||||
<section class="ticket-detail-section">
|
||||
<div class="ticket-section-heading"><h2>Review and integration status</h2></div>
|
||||
<dl class="ticket-facts">
|
||||
<div><dt>Source review freshness</dt><dd>{sourceReviewFreshness(mergeRequest)}</dd></div>
|
||||
<div><dt>{mergeRequest.state === "merged" ? "Recorded integration approval" : "Live source review freshness"}</dt><dd>{sourceReviewFreshness(mergeRequest)}</dd></div>
|
||||
<div><dt>Target integration</dt><dd>{targetIntegrationStatus(mergeRequest)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
parseTicketRoleAssignmentMutationResponse,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||
import { summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||
import {
|
||||
relationLabel,
|
||||
TICKET_STATES,
|
||||
@@ -681,6 +682,21 @@
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Current requirement evidence</h2></header>
|
||||
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
|
||||
<p><strong>Current requirement approval:</strong> {ticket.evidence.approved_current_subject ? "approved" : "not established"}.</p>
|
||||
{#if ticket.evidence.missing.length > 0}
|
||||
<ul>
|
||||
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="workspace-empty-copy">This evidence evaluates current Ticket requirements, separately from persisted integration approval.</p>
|
||||
{#if ticket.state === "done"}
|
||||
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Merge Requests</h2></header>
|
||||
{#if mergeRequests.length > 0}
|
||||
@@ -691,17 +707,16 @@
|
||||
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
|
||||
to <code>{mergeRequest.selector_to}</code>
|
||||
</p>
|
||||
{#if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref === mergeRequest.current_subject_ref}
|
||||
<p><strong>Source review:</strong> {mergeRequest.review_status} for exact ref <code>{mergeRequest.current_subject_ref}</code></p>
|
||||
{:else if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref}
|
||||
<p><strong>Fresh source review required:</strong> selector_from moved from <code>{mergeRequest.review_subject_ref}</code> to <code>{mergeRequest.current_subject_ref}</code>.</p>
|
||||
{:else if mergeRequest.current_subject_ref}
|
||||
<p><strong>Fresh source review required:</strong> no effective verdict exists for <code>{mergeRequest.current_subject_ref}</code>.</p>
|
||||
{:else}
|
||||
<p><strong>Source review unavailable:</strong> selector_from is unresolved.</p>
|
||||
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
|
||||
{#if mergeRequest.integration_evidence_error !== null}
|
||||
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
|
||||
{/if}
|
||||
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
|
||||
{#if mergeRequest.state === "merged"}
|
||||
<p class="workspace-empty-copy">The merged source and integration approval are persisted evidence, not a live selector observation.</p>
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
|
||||
{/if}
|
||||
<a
|
||||
class="workspace-secondary-button"
|
||||
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
||||
|
||||
Reference in New Issue
Block a user