feat: add compact Ticket and MR read views
T-848: deduplicate model-facing bodies, preserve exact post reads and paged history, and derive MR status from complete evidence rather than the returned page. Update strict browser parsing and generated contracts.
This commit is contained in:
@@ -8978,6 +8978,9 @@ pub struct MergeRequestListQuery {
|
|||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct MergeRequestThreadQuery {
|
pub struct MergeRequestThreadQuery {
|
||||||
|
/// Show only: summary omits history; detail returns an exact bounded thread page.
|
||||||
|
/// Omission retains the existing HTTP detail response.
|
||||||
|
pub view: Option<MergeRequestReadView>,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub after: Option<u64>,
|
pub after: Option<u64>,
|
||||||
#[schemars(range(min = 0, max = 1000))]
|
#[schemars(range(min = 0, max = 1000))]
|
||||||
@@ -9005,6 +9008,73 @@ pub struct MergeRequestLinkedTicketResponse {
|
|||||||
pub key: Option<String>,
|
pub key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MergeRequestReadView {
|
||||||
|
Summary,
|
||||||
|
#[default]
|
||||||
|
Detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestEventReference {
|
||||||
|
pub event_id: String,
|
||||||
|
/// Exact event: read detail with after = sequence - 1 and limit = 1.
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
pub sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestReviewSummary {
|
||||||
|
pub event: MergeRequestEventReference,
|
||||||
|
pub request_event_id: String,
|
||||||
|
pub subject_ref: String,
|
||||||
|
pub ticket_content_digest: String,
|
||||||
|
pub decision: ReviewDecision,
|
||||||
|
/// Findings retain their order in the exact review event; no finding body is truncated here.
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub findings_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computed from complete persisted evidence, never from the returned history page.
|
||||||
|
/// This observation is not integration authority or current-requirements approval.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestStatusSummary {
|
||||||
|
/// unknown, none, pending, approved, or request_changes for the observed source.
|
||||||
|
pub review_status: String,
|
||||||
|
pub review: Option<MergeRequestReviewSummary>,
|
||||||
|
pub latest_review_subject_ref: Option<String>,
|
||||||
|
pub latest_event: Option<MergeRequestEventReference>,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub thread_event_count: usize,
|
||||||
|
/// Existing readiness semantics; review bodies are omitted in this compact projection.
|
||||||
|
pub readiness: MergeRequestReadinessResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MergeRequestEventPage {
|
||||||
|
/// Summary deliberately omits events. Read detail from the beginning to obtain history.
|
||||||
|
pub omitted: bool,
|
||||||
|
pub has_more: bool,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
pub next_after: Option<u64>,
|
||||||
|
#[schemars(range(min = 1, max = 200))]
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -9014,6 +9084,9 @@ pub struct MergeRequestDetailResponse {
|
|||||||
pub source: MergeRequestRefResponse,
|
pub source: MergeRequestRefResponse,
|
||||||
pub target: MergeRequestRefResponse,
|
pub target: MergeRequestRefResponse,
|
||||||
pub linked_tickets: Vec<MergeRequestLinkedTicketResponse>,
|
pub linked_tickets: Vec<MergeRequestLinkedTicketResponse>,
|
||||||
|
pub view: MergeRequestReadView,
|
||||||
|
pub summary: MergeRequestStatusSummary,
|
||||||
|
pub event_page: MergeRequestEventPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
|||||||
+319
-38
@@ -132,9 +132,7 @@ System events and case judgments cannot be edited. The Workspace backend supplie
|
|||||||
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
||||||
overview. Workspace-backed Workers replace this definition with the richer authoritative \
|
overview. Workspace-backed Workers replace this definition with the richer authoritative \
|
||||||
text/event/evidence/relation/Objective/time/attention query.";
|
text/event/evidence/relation/Objective/time/attention query.";
|
||||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
|
const SHOW_DESCRIPTION: &str = "Read one Ticket by id or exact query. Use view=summary for status/CAS without prose or history; detail (default) for canonical initial_post and bounded recent discussion; history with event_cursor for older thread pages; post with an observed event_ref for the exact full post. Never use truncated bodies for edit CAS or guess event identities. Workspace-backed Workers retain the same views with authoritative delegation and MR evidence.";
|
||||||
Ticket backend. Output includes the initial comment and recent events with stable event_ref, author/time and edit metadata, bounded Markdown body, resolution, and artifact \
|
|
||||||
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
|
|
||||||
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
||||||
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
|
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
|
||||||
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
||||||
@@ -549,8 +547,50 @@ impl QueryTicketParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Model-facing read modes shared by generic Ticket and Workspace Tool/WIP reads.
|
||||||
|
#[derive(
|
||||||
|
Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ShowTicketView {
|
||||||
|
Summary,
|
||||||
|
#[default]
|
||||||
|
Detail,
|
||||||
|
History,
|
||||||
|
Post,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowTicketView {
|
||||||
|
pub fn validate(
|
||||||
|
self,
|
||||||
|
event_cursor: Option<&str>,
|
||||||
|
event_ref: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if (self == Self::Post) != event_ref.is_some()
|
||||||
|
|| event_ref.is_some_and(|value| value.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"event_ref is required only for view=post; use an observed identity".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if event_cursor.is_some() && !matches!(self, Self::Detail | Self::History) {
|
||||||
|
return Err("event_cursor is only valid for detail/history".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct ShowTicketParams {
|
struct ShowTicketParams {
|
||||||
|
/// summary omits prose/history; detail is the default; history returns only a thread page; post returns an exact full post up to 1 MiB (larger posts fail, never truncate).
|
||||||
|
#[serde(default)]
|
||||||
|
view: ShowTicketView,
|
||||||
|
/// Opaque continuation from event_page. Valid for detail/history only.
|
||||||
|
#[serde(default)]
|
||||||
|
event_cursor: Option<String>,
|
||||||
|
/// Exact observed post identity. Required only for view=post.
|
||||||
|
#[serde(default)]
|
||||||
|
event_ref: Option<String>,
|
||||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
@@ -1096,6 +1136,10 @@ impl Tool for ShowTicketTool {
|
|||||||
_ctx: agen::tool::ToolExecutionContext,
|
_ctx: agen::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
|
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
|
||||||
|
params
|
||||||
|
.view
|
||||||
|
.validate(params.event_cursor.as_deref(), params.event_ref.as_deref())
|
||||||
|
.map_err(ToolError::InvalidArgument)?;
|
||||||
let query = id_or_query(params.id, params.query)?;
|
let query = id_or_query(params.id, params.query)?;
|
||||||
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
|
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
|
||||||
let artifact_limit = bounded(
|
let artifact_limit = bounded(
|
||||||
@@ -1117,10 +1161,45 @@ impl Tool for ShowTicketTool {
|
|||||||
ticket.meta.id,
|
ticket.meta.id,
|
||||||
ticket.meta.workflow_state.as_str()
|
ticket.meta.workflow_state.as_str()
|
||||||
);
|
);
|
||||||
Ok(json_output(
|
let value = match params.view {
|
||||||
summary,
|
ShowTicketView::Summary => ticket_status_json(&ticket),
|
||||||
ticket_json(&ticket, event_limit, artifact_limit, body_max_bytes),
|
ShowTicketView::Post => {
|
||||||
))
|
let event = ticket
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| Some(event.event_ref.as_str()) == params.event_ref.as_deref())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidArgument(
|
||||||
|
"Observed post identity not found in this Ticket".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Match the Workspace full-post route: never silently truncate CAS input.
|
||||||
|
if event.body.as_str().len() > 1024 * 1024 {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"Exact post exceeds the 1 MiB full-read limit; do not edit from a truncated preview".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
json!({"ticket": ticket.meta.id, "post": ticket_event_json(event, usize::MAX)})
|
||||||
|
}
|
||||||
|
ShowTicketView::Detail | ShowTicketView::History => {
|
||||||
|
let (events, page) = ticket_event_page(
|
||||||
|
&ticket,
|
||||||
|
params.event_cursor.as_deref(),
|
||||||
|
event_limit,
|
||||||
|
body_max_bytes,
|
||||||
|
)?;
|
||||||
|
if params.view == ShowTicketView::History {
|
||||||
|
json!({"ticket": ticket.meta.id, "thread": events, "event_page": page})
|
||||||
|
} else {
|
||||||
|
let mut detail =
|
||||||
|
ticket_json(&ticket, event_limit, artifact_limit, body_max_bytes);
|
||||||
|
detail["events"] = events;
|
||||||
|
detail["event_page"] = page;
|
||||||
|
detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(json_output(summary, value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1694,8 +1773,70 @@ fn ticket_relations_json(ticket: &Ticket) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_event_json(event: &crate::TicketEvent, body_max_bytes: usize) -> Value {
|
fn ticket_status_json(ticket: &Ticket) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
"ticket": ticket.meta.resource_key.as_deref().unwrap_or(&ticket.meta.id),
|
||||||
|
"title": ticket.meta.title,
|
||||||
|
"state": ticket.meta.workflow_state,
|
||||||
|
"content_digest": crate::ticket_content_digest(ticket),
|
||||||
|
"closure_operation_key": ticket.closure_operation_key,
|
||||||
|
"current_closure": ticket.current_closure.as_ref().map(|j| json!({
|
||||||
|
"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "at": j.at
|
||||||
|
})),
|
||||||
|
"targets": ticket.meta.targets,
|
||||||
|
"tags": ticket.tags,
|
||||||
|
"assignee": ticket.meta.assignee,
|
||||||
|
"readiness": ticket.meta.readiness,
|
||||||
|
"updated_at": ticket.meta.updated_at,
|
||||||
|
"latest_event_ref": ticket.events.last().map(|event| &event.event_ref),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor is the persisted identity of the oldest returned event, never an offset.
|
||||||
|
// Appending newer events between pages therefore cannot shift the continuation.
|
||||||
|
fn ticket_event_page(
|
||||||
|
ticket: &Ticket,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
body_max_bytes: usize,
|
||||||
|
) -> Result<(Value, Value), ToolError> {
|
||||||
|
let events: Vec<_> = ticket
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| !event.is_initial)
|
||||||
|
.collect();
|
||||||
|
let end = match cursor {
|
||||||
|
None => events.len(),
|
||||||
|
Some(cursor) => events
|
||||||
|
.iter()
|
||||||
|
.position(|event| event.event_ref == cursor)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidArgument(
|
||||||
|
"Unknown Ticket event_cursor; reuse a returned cursor".into(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
let start = end.saturating_sub(limit.max(1));
|
||||||
|
let items: Vec<_> = events[start..end]
|
||||||
|
.iter()
|
||||||
|
.map(|event| ticket_event_json(event, body_max_bytes))
|
||||||
|
.collect();
|
||||||
|
let next_cursor = if start > 0 {
|
||||||
|
Some(&events[start].event_ref)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
json!({"count": events.len(), "returned": items.len(), "truncated": start > 0, "items": items}),
|
||||||
|
json!({"next_cursor": next_cursor, "has_more": start > 0}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_event_json(event: &crate::TicketEvent, body_max_bytes: usize) -> Value {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
json!({
|
||||||
|
"body_digest": Sha256::digest(event.body.as_str().as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
|
||||||
|
"editable": event.kind == TicketEventKind::Comment,
|
||||||
"event_ref": event.event_ref,
|
"event_ref": event.event_ref,
|
||||||
"is_initial": event.is_initial,
|
"is_initial": event.is_initial,
|
||||||
"kind": event.kind.as_str(),
|
"kind": event.kind.as_str(),
|
||||||
@@ -1722,13 +1863,8 @@ fn ticket_json(
|
|||||||
artifact_limit: usize,
|
artifact_limit: usize,
|
||||||
body_max_bytes: usize,
|
body_max_bytes: usize,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let event_count = ticket.events.len();
|
let (events, event_page) = ticket_event_page(ticket, None, event_limit, body_max_bytes)
|
||||||
let events: Vec<_> = ticket
|
.expect("first thread page has no cursor to reject");
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.skip(event_count.saturating_sub(event_limit))
|
|
||||||
.map(|event| ticket_event_json(event, body_max_bytes))
|
|
||||||
.collect();
|
|
||||||
let artifact_count = ticket.artifacts.len();
|
let artifact_count = ticket.artifacts.len();
|
||||||
let artifacts: Vec<_> = ticket
|
let artifacts: Vec<_> = ticket
|
||||||
.artifacts
|
.artifacts
|
||||||
@@ -1753,17 +1889,12 @@ fn ticket_json(
|
|||||||
},
|
},
|
||||||
"content_digest": crate::ticket_content_digest(ticket),
|
"content_digest": crate::ticket_content_digest(ticket),
|
||||||
"closure_operation_key": ticket.closure_operation_key,
|
"closure_operation_key": ticket.closure_operation_key,
|
||||||
"current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})),
|
"current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "references": j.references, "at": j.at})),
|
||||||
"case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})).collect::<Vec<_>>() },
|
"case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "author": j.author, "references": j.references, "at": j.at})).collect::<Vec<_>>() },
|
||||||
"legacy_workflow_snapshot": ticket.legacy_workflow_snapshot,
|
"legacy_workflow_snapshot": ticket.legacy_workflow_snapshot,
|
||||||
"initial_post": ticket.events.iter().find(|event| event.is_initial).map(|event| ticket_event_json(event, body_max_bytes)),
|
"initial_post": ticket.events.iter().find(|event| event.is_initial).map(|event| ticket_event_json(event, body_max_bytes)),
|
||||||
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
|
"events": events,
|
||||||
"events": {
|
"event_page": event_page,
|
||||||
"count": event_count,
|
|
||||||
"returned": events.len(),
|
|
||||||
"truncated": event_count > events.len(),
|
|
||||||
"items": events,
|
|
||||||
},
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"count": artifact_count,
|
"count": artifact_count,
|
||||||
"returned": artifacts.len(),
|
"returned": artifacts.len(),
|
||||||
@@ -2063,13 +2194,17 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
assert_eq!(value["events"]["count"], 2);
|
assert_eq!(value["events"]["count"], 1);
|
||||||
let projected = value["events"]["items"]
|
let projected = if post.is_initial {
|
||||||
.as_array()
|
&value["initial_post"]
|
||||||
.unwrap()
|
} else {
|
||||||
.iter()
|
value["events"]["items"]
|
||||||
.find(|e| e["event_ref"] == post.event_ref)
|
.as_array()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|e| e["event_ref"] == post.event_ref)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
assert_eq!(projected["author"], json!(post.author));
|
assert_eq!(projected["author"], json!(post.author));
|
||||||
assert_eq!(projected["at"], json!(post.at));
|
assert_eq!(projected["at"], json!(post.at));
|
||||||
assert_eq!(projected["is_initial"], post.is_initial);
|
assert_eq!(projected["is_initial"], post.is_initial);
|
||||||
@@ -2104,7 +2239,8 @@ mod tests {
|
|||||||
value["initial_post"]["event_ref"],
|
value["initial_post"]["event_ref"],
|
||||||
before.events[0].event_ref
|
before.events[0].event_ref
|
||||||
);
|
);
|
||||||
assert_eq!(value["initial_post"]["body"], value["body"]);
|
assert_eq!(value["initial_post"]["body"], "Edited Initial body");
|
||||||
|
assert!(value.get("body").is_none());
|
||||||
assert_eq!(value["meta"]["created_by"], "creator");
|
assert_eq!(value["meta"]["created_by"], "creator");
|
||||||
assert_eq!(value["initial_post"]["author"], "creator");
|
assert_eq!(value["initial_post"]["author"], "creator");
|
||||||
let initial_keys = value["initial_post"]
|
let initial_keys = value["initial_post"]
|
||||||
@@ -2518,7 +2654,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value["body"].as_str().unwrap().trim_start_matches('\n'),
|
value["initial_post"]["body"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.trim_start_matches('\n'),
|
||||||
"ONE\ntwo\nONE\n"
|
"ONE\ntwo\nONE\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2980,13 +3119,13 @@ mod tests {
|
|||||||
assert_eq!(value["closure_operation_key"], "tool-correct");
|
assert_eq!(value["closure_operation_key"], "tool-correct");
|
||||||
assert_eq!(value["case_history"]["count"], 2);
|
assert_eq!(value["case_history"]["count"], 2);
|
||||||
assert_eq!(value["case_history"]["items"].as_array().unwrap().len(), 1);
|
assert_eq!(value["case_history"]["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert!(value["current_closure"].get("explanation").is_none());
|
||||||
assert!(
|
assert!(
|
||||||
value["current_closure"]["explanation"]
|
value["case_history"]["items"][0]
|
||||||
.as_str()
|
.get("explanation")
|
||||||
.unwrap()
|
.is_none()
|
||||||
.len()
|
|
||||||
< 256
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(value["events"]["items"][0]["body_truncated"], true);
|
||||||
assert!(value["content_digest"].as_str().unwrap().len() == 64);
|
assert!(value["content_digest"].as_str().unwrap().len() == 64);
|
||||||
let mut reopen = correction;
|
let mut reopen = correction;
|
||||||
reopen["expected_closure_operation_key"] = json!("tool-correct");
|
reopen["expected_closure_operation_key"] = json!("tool-correct");
|
||||||
@@ -3015,6 +3154,148 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn show_views_deduplicate_bound_history_and_read_exact_unicode_posts() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let body = "long-requirement-".repeat(700);
|
||||||
|
let mut input = NewTicket::new("Read projections");
|
||||||
|
input.body = MarkdownText::new(body.clone());
|
||||||
|
let reference = backend.create(input).unwrap();
|
||||||
|
for index in 0..5 {
|
||||||
|
backend
|
||||||
|
.add_event(
|
||||||
|
reference.id.clone().into(),
|
||||||
|
NewTicketEvent::new(TicketEventKind::Comment, format!("comment-{index}")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before = backend.show(reference.id.clone().into()).unwrap();
|
||||||
|
let detail = ticket_json(&before, 20, 20, DEFAULT_BODY_MAX_BYTES);
|
||||||
|
assert_eq!(detail.to_string().matches(&body).count(), 1);
|
||||||
|
assert!(detail.get("body").is_none());
|
||||||
|
assert!(
|
||||||
|
detail["events"]["items"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|event| event["is_initial"] == false)
|
||||||
|
);
|
||||||
|
let mut larger = before.clone();
|
||||||
|
larger.document.body = MarkdownText::new("prose".repeat(100_000));
|
||||||
|
larger.events[0].body = larger.document.body.clone();
|
||||||
|
larger
|
||||||
|
.events
|
||||||
|
.extend(vec![before.events.last().unwrap().clone(); 1000]);
|
||||||
|
let small_summary = ticket_status_json(&before).to_string();
|
||||||
|
let large_summary = ticket_status_json(&larger).to_string();
|
||||||
|
assert_eq!(small_summary.len(), large_summary.len());
|
||||||
|
eprintln!(
|
||||||
|
"Ticket generic bytes: detail={}, summary={} (unchanged with 500KB body / 1000 extra events)",
|
||||||
|
detail.to_string().len(),
|
||||||
|
small_summary.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let show = tool_by_name(backend.clone(), "ShowTicket");
|
||||||
|
let mut cursor = Value::Null;
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
loop {
|
||||||
|
let output = show.execute(&json!({"id": reference.id, "view": "history", "event_limit": 2, "event_cursor": cursor}).to_string(), Default::default()).await.unwrap();
|
||||||
|
let page: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(page.as_object().unwrap().len(), 3);
|
||||||
|
for event in page["thread"]["items"].as_array().unwrap() {
|
||||||
|
seen.push(event["event_ref"].as_str().unwrap().to_owned());
|
||||||
|
}
|
||||||
|
if cursor.is_null() {
|
||||||
|
backend
|
||||||
|
.add_event(
|
||||||
|
reference.id.clone().into(),
|
||||||
|
NewTicketEvent::new(TicketEventKind::Comment, "newer append"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
cursor = page["event_page"]["next_cursor"].clone();
|
||||||
|
if cursor.is_null() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.sort();
|
||||||
|
let mut expected: Vec<_> = before
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| !event.is_initial)
|
||||||
|
.map(|event| event.event_ref.clone())
|
||||||
|
.collect();
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(seen, expected);
|
||||||
|
assert!(
|
||||||
|
show.execute(
|
||||||
|
&json!({"id": reference.id, "view":"history", "event_cursor":"guessed"})
|
||||||
|
.to_string(),
|
||||||
|
Default::default()
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let unicode = "全文🦀é\n".repeat(20_000);
|
||||||
|
let post = &before.events[0];
|
||||||
|
let edit = tool_by_name(backend.clone(), "TicketEditPost");
|
||||||
|
let edit_args = json!({"ticket":reference.id, "event_ref":post.event_ref, "expected_body":post.body.as_str(), "body":unicode});
|
||||||
|
edit.execute(&edit_args.to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let full = show.execute(&json!({"id":reference.id, "view":"post", "event_ref":post.event_ref, "body_max_bytes":1}).to_string(), Default::default()).await.unwrap();
|
||||||
|
let full: Value = serde_json::from_str(&full.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(full["post"]["body"], unicode);
|
||||||
|
assert_eq!(full["post"]["body_truncated"], false);
|
||||||
|
assert_eq!(full["post"]["editable"], true);
|
||||||
|
assert!(
|
||||||
|
edit.execute(&edit_args.to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for args in [
|
||||||
|
json!({"view":"post"}),
|
||||||
|
json!({"view":"summary", "event_ref":post.event_ref}),
|
||||||
|
json!({"view":"summary", "event_cursor":"x"}),
|
||||||
|
] {
|
||||||
|
let mut args = args;
|
||||||
|
args["id"] = json!(reference.id);
|
||||||
|
assert!(
|
||||||
|
show.execute(&args.to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exact_post_read_rejects_oversize_instead_of_truncating_edit_input() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let mut input = NewTicket::new("Full-post limit");
|
||||||
|
input.body = MarkdownText::new("x".repeat(1024 * 1024 + 1));
|
||||||
|
let reference = backend.create(input).unwrap();
|
||||||
|
let observed = backend.show(reference.id.clone().into()).unwrap();
|
||||||
|
let event_ref = &observed
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.is_initial)
|
||||||
|
.unwrap()
|
||||||
|
.event_ref;
|
||||||
|
let show = tool_by_name(backend.clone(), "ShowTicket");
|
||||||
|
let error = show
|
||||||
|
.execute(
|
||||||
|
&json!({"id":reference.id, "view":"post", "event_ref":event_ref}).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("1 MiB"));
|
||||||
|
assert_eq!(backend.show(reference.id.into()).unwrap(), observed);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn case_tool_rejects_legacy_states_and_unrecognized_input_fields() {
|
async fn case_tool_rejects_legacy_states_and_unrecognized_input_fields() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -91,13 +91,22 @@ struct MergeRequestInput {
|
|||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct ShowMergeRequestInput {
|
struct ShowMergeRequestInput {
|
||||||
merge_request_id: String,
|
merge_request_id: String,
|
||||||
|
/// summary (default) omits review bodies/history; detail returns exact paged events.
|
||||||
|
/// For compatibility, supplying after/limit without view selects detail.
|
||||||
|
view: Option<MergeRequestReadView>,
|
||||||
/// Return thread events strictly after this sequence.
|
/// Return thread events strictly after this sequence.
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
after: Option<u64>,
|
after: Option<u64>,
|
||||||
/// Bounded thread page size. The Backend also enforces its authoritative cap.
|
/// Exact detail/history page size (default 20, maximum 200). Continue with event_page.next_after.
|
||||||
#[schemars(range(min = 1, max = 200))]
|
#[schemars(range(min = 1, max = 200))]
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum MergeRequestReadView {
|
||||||
|
Summary,
|
||||||
|
Detail,
|
||||||
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct OpenMergeRequestInput {
|
struct OpenMergeRequestInput {
|
||||||
ticket: String,
|
ticket: String,
|
||||||
@@ -203,7 +212,27 @@ impl Tool for MergeRequestTool {
|
|||||||
"limit must be between 1 and 200".into(),
|
"limit must be between 1 and 200".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return self.show_merge_request(ws, &value.merge_request_id, value.after, value.limit);
|
let view = value.view.unwrap_or_else(|| {
|
||||||
|
if value.after.is_some() || value.limit.is_some() {
|
||||||
|
MergeRequestReadView::Detail
|
||||||
|
} else {
|
||||||
|
MergeRequestReadView::Summary
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if matches!(view, MergeRequestReadView::Summary)
|
||||||
|
&& (value.after.is_some() || value.limit.is_some())
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"after/limit require view=detail".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return self.show_merge_request(
|
||||||
|
ws,
|
||||||
|
&value.merge_request_id,
|
||||||
|
view,
|
||||||
|
value.after,
|
||||||
|
value.limit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let (method, path, body) = match self.kind {
|
let (method, path, body) = match self.kind {
|
||||||
Kind::Readiness => {
|
Kind::Readiness => {
|
||||||
@@ -290,12 +319,19 @@ impl MergeRequestTool {
|
|||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
merge_request_id: &str,
|
merge_request_id: &str,
|
||||||
|
view: MergeRequestReadView,
|
||||||
after: Option<u64>,
|
after: Option<u64>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let merge_request_id = encode_path_segment(merge_request_id);
|
let merge_request_id = encode_path_segment(merge_request_id);
|
||||||
let mut path = format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}");
|
let mut path = format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}");
|
||||||
let mut query = Vec::new();
|
let mut query = vec![format!(
|
||||||
|
"view={}",
|
||||||
|
match view {
|
||||||
|
MergeRequestReadView::Summary => "summary",
|
||||||
|
MergeRequestReadView::Detail => "detail",
|
||||||
|
}
|
||||||
|
)];
|
||||||
if let Some(after) = after {
|
if let Some(after) = after {
|
||||||
query.push(format!("after={after}"));
|
query.push(format!("after={after}"));
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1135,7 @@ fn merge_request_operation_not_found() -> WipOperationError {
|
|||||||
pub fn description(n: &str) -> Option<&'static str> {
|
pub fn description(n: &str) -> Option<&'static str> {
|
||||||
match n {
|
match n {
|
||||||
"ShowMergeRequest" => Some(
|
"ShowMergeRequest" => Some(
|
||||||
"Read the selector-based Merge Request, append-only thread, source-review freshness, and target-integration evidence before review, fix, or handoff decisions.",
|
"Read an authoritative compact MR summary by default: source/target observations, full-evidence review status/readiness and stable event references, without review bodies/history. Use view=detail with bounded after/limit for exact reviews/findings and history; continue with event_page.next_after. Status is never inferred from a history page. Summary is not approval or integration authority.",
|
||||||
),
|
),
|
||||||
"CheckMergeRequestReadiness" => Some(
|
"CheckMergeRequestReadiness" => Some(
|
||||||
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
||||||
@@ -1222,7 +1258,10 @@ mod tests {
|
|||||||
let requests = client.requests.lock().expect("request lock");
|
let requests = client.requests.lock().expect("request lock");
|
||||||
assert_eq!(requests.len(), 1);
|
assert_eq!(requests.len(), 1);
|
||||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||||
assert_eq!(requests[0].path, "/api/w/ws/merge-requests/MR%2F1");
|
assert_eq!(
|
||||||
|
requests[0].path,
|
||||||
|
"/api/w/ws/merge-requests/MR%2F1?view=summary"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1243,7 +1282,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.requests.lock().expect("request lock")[0].path,
|
client.requests.lock().expect("request lock")[0].path,
|
||||||
"/api/w/ws/merge-requests/MR-1?after=42&limit=200"
|
"/api/w/ws/merge-requests/MR-1?view=detail&after=42&limit=200"
|
||||||
);
|
);
|
||||||
|
|
||||||
let rejected = tool
|
let rejected = tool
|
||||||
@@ -1257,6 +1296,64 @@ mod tests {
|
|||||||
assert_eq!(client.requests.lock().expect("request lock").len(), 1);
|
assert_eq!(client.requests.lock().expect("request lock").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_read_forwards_explicit_projection_and_rejects_ambiguous_summary_paging() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
|
"merge_request_id": "MR-1", "view": "detail", "thread": [],
|
||||||
|
"summary": {"review_status": "approved"},
|
||||||
|
"event_page": {"omitted": false, "has_more": true, "next_after": 9, "limit": 1}
|
||||||
|
}))]));
|
||||||
|
let tool = native_merge_request_tools(
|
||||||
|
client.clone(),
|
||||||
|
MergeRequestFeatureConfig {
|
||||||
|
show: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
let input = native_merge_request_input(
|
||||||
|
&BTreeMap::from([
|
||||||
|
("view".into(), WipValue::String("detail".into())),
|
||||||
|
("after".into(), json_to_wip(&json!(8)).unwrap()),
|
||||||
|
("limit".into(), json_to_wip(&json!(1)).unwrap()),
|
||||||
|
]),
|
||||||
|
Some("MR-1"),
|
||||||
|
Some("merge_request_id"),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("native read arguments should bind"));
|
||||||
|
let output = execute_native_merge_request_tool(
|
||||||
|
&tool,
|
||||||
|
&None,
|
||||||
|
input,
|
||||||
|
Some("MR-1"),
|
||||||
|
WipCallContext {
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "reader".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("native detail read should succeed"));
|
||||||
|
let output = merge_request_tool_output_json(output)
|
||||||
|
.unwrap_or_else(|_| panic!("valid JSON response"));
|
||||||
|
assert_eq!(output["summary"]["review_status"], "approved");
|
||||||
|
assert_eq!(output["event_page"]["next_after"], 9);
|
||||||
|
assert_eq!(
|
||||||
|
client.requests.lock().unwrap()[0].path,
|
||||||
|
"/api/w/ws/merge-requests/MR-1?view=detail&after=8&limit=1"
|
||||||
|
);
|
||||||
|
let rejected = tool
|
||||||
|
.tool
|
||||||
|
.execute(
|
||||||
|
r#"{"merge_request_id":"MR-1","view":"summary","limit":1}"#,
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(rejected.to_string().contains("require view=detail"));
|
||||||
|
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_requires_explicit_merge_request_identity() {
|
async fn show_requires_explicit_merge_request_identity() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct ModelTicketQueryItem {
|
|||||||
title: String,
|
title: String,
|
||||||
state: ticket::TicketState,
|
state: ticket::TicketState,
|
||||||
closure_operation_key: Option<String>,
|
closure_operation_key: Option<String>,
|
||||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
current_closure: Option<Value>,
|
||||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||||
tags: Vec<ticket::WorkspaceTag>,
|
tags: Vec<ticket::WorkspaceTag>,
|
||||||
@@ -34,7 +34,6 @@ struct ModelTicketQueryItem {
|
|||||||
unresolved_review_count: usize,
|
unresolved_review_count: usize,
|
||||||
evidence: Option<ModelTicketEvidence>,
|
evidence: Option<ModelTicketEvidence>,
|
||||||
merge_requests: Vec<ModelMergeRequest>,
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, Serialize)]
|
#[derive(Debug, serde::Deserialize, Serialize)]
|
||||||
@@ -50,14 +49,12 @@ pub(super) struct ModelTicketDetail {
|
|||||||
ticket: String,
|
ticket: String,
|
||||||
content_digest: String,
|
content_digest: String,
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
|
||||||
body_truncated: bool,
|
|
||||||
initial_post: ModelTicketEvent,
|
initial_post: ModelTicketEvent,
|
||||||
targets: Vec<ModelTicketTarget>,
|
targets: Vec<ModelTicketTarget>,
|
||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
state: ticket::TicketState,
|
state: ticket::TicketState,
|
||||||
closure_operation_key: Option<String>,
|
closure_operation_key: Option<String>,
|
||||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
current_closure: Option<Value>,
|
||||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||||
tags: Vec<ticket::WorkspaceTag>,
|
tags: Vec<ticket::WorkspaceTag>,
|
||||||
@@ -65,7 +62,7 @@ pub(super) struct ModelTicketDetail {
|
|||||||
priority: Option<String>,
|
priority: Option<String>,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
updated_at: Option<String>,
|
updated_at: Option<String>,
|
||||||
closure_history: Vec<ticket::TicketCaseHistoryEntry>,
|
closure_history: Vec<Value>,
|
||||||
thread: Vec<ModelTicketEvent>,
|
thread: Vec<ModelTicketEvent>,
|
||||||
relations: ModelTicketRelations,
|
relations: ModelTicketRelations,
|
||||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||||
@@ -73,7 +70,6 @@ pub(super) struct ModelTicketDetail {
|
|||||||
current_worker: Option<ModelWorkerSummary>,
|
current_worker: Option<ModelWorkerSummary>,
|
||||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||||
merge_requests: Vec<ModelMergeRequest>,
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
|
||||||
evidence: Option<ModelTicketEvidence>,
|
evidence: Option<ModelTicketEvidence>,
|
||||||
actions: Option<ModelTicketActions>,
|
actions: Option<ModelTicketActions>,
|
||||||
event_page: Option<ModelEventPage>,
|
event_page: Option<ModelEventPage>,
|
||||||
@@ -280,6 +276,21 @@ fn typed_field<T: serde::de::DeserializeOwned>(
|
|||||||
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
|
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn closure_metadata<T: Serialize>(value: T) -> Result<Value, String> {
|
||||||
|
let mut value = serde_json::to_value(value).map_err(|error| error.to_string())?;
|
||||||
|
if let Some(object) = value.as_object_mut() {
|
||||||
|
// The full explanation is available in thread history/post, not repeated
|
||||||
|
// in current judgment and closure-index metadata as well.
|
||||||
|
object.remove("explanation");
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_current_closure(root: &Map<String, Value>) -> Result<Option<Value>, String> {
|
||||||
|
let closure: Option<ticket::TicketClosureJudgment> = typed_field(root, "current_closure")?;
|
||||||
|
closure.map(closure_metadata).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
||||||
let root = object(&value, "Ticket query response")?;
|
let root = object(&value, "Ticket query response")?;
|
||||||
let page = object_field(root, "page")?;
|
let page = object_field(root, "page")?;
|
||||||
@@ -301,7 +312,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
title: string_field(item, "title")?,
|
title: string_field(item, "title")?,
|
||||||
state: typed_field(item, "state")?,
|
state: typed_field(item, "state")?,
|
||||||
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
||||||
current_closure: typed_field(item, "current_closure")?,
|
current_closure: project_current_closure(item)?,
|
||||||
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
||||||
assignment_tracking: project_assignment_tracking(item)?,
|
assignment_tracking: project_assignment_tracking(item)?,
|
||||||
tags: project_tags(item)?,
|
tags: project_tags(item)?,
|
||||||
@@ -330,14 +341,84 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
.iter()
|
.iter()
|
||||||
.map(project_merge_request)
|
.map(project_merge_request)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
merge_request: item
|
|
||||||
.get("merge_request")
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
.map(project_merge_request)
|
|
||||||
.transpose()?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Status is deliberately projected from authority fields, not from serialized detail:
|
||||||
|
/// prose/history size and history parsing have no effect on this response.
|
||||||
|
pub(super) fn project_ticket_status(value: Value) -> Result<Value, String> {
|
||||||
|
use serde_json::json;
|
||||||
|
let root = object(&value, "Ticket summary response")?;
|
||||||
|
let state: ticket::TicketState = typed_field(root, "state")?;
|
||||||
|
let closure_key: Option<String> = typed_field(root, "closure_operation_key")?;
|
||||||
|
let closure: Option<ticket::TicketClosureJudgment> = typed_field(root, "current_closure")?;
|
||||||
|
let targets: Vec<ModelTicketTarget> = typed_field(root, "targets")?;
|
||||||
|
let tracking = project_assignment_tracking(root)?.into_iter().map(|row| json!({
|
||||||
|
"assignment_id": row.assignment_id, "role": row.role, "principal": row.principal,
|
||||||
|
"assigned_by": row.assigned_by, "assigned_at": row.assigned_at, "current": row.current,
|
||||||
|
"status": row.tracking.map(|tracking| tracking.status),
|
||||||
|
"last_event_id": row.last_event_id, "updated_at": row.updated_at,
|
||||||
|
})).collect::<Vec<_>>();
|
||||||
|
let merges = array_field(root, "merge_requests")?
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
let mut merge = serde_json::to_value(project_merge_request(value)?)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
merge
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("typed merge object")
|
||||||
|
.remove("review_excerpt");
|
||||||
|
Ok(merge)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<Value>, String>>()?;
|
||||||
|
let latest_event_ref = array_field(root, "events")?
|
||||||
|
.last()
|
||||||
|
.map(|event| string_field(object(event, "Ticket event")?, "event_ref"))
|
||||||
|
.transpose()?;
|
||||||
|
Ok(json!({
|
||||||
|
"ticket": resource_ref(root, "resource_key", "T-")?,
|
||||||
|
"title": string_field(root, "title")?,
|
||||||
|
"content_digest": string_field(root, "content_digest")?,
|
||||||
|
"state": state, "closure_operation_key": closure_key,
|
||||||
|
"current_closure": closure.map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "at": j.at})),
|
||||||
|
"targets": targets, "tags": project_tags(root)?,
|
||||||
|
"readiness": optional_string(root, "readiness")?,
|
||||||
|
"priority": optional_string(root, "priority")?,
|
||||||
|
"updated_at": optional_string(root, "updated_at")?,
|
||||||
|
"assignment_tracking": tracking,
|
||||||
|
"current_worker": root.get("current_worker").filter(|value| !value.is_null()).map(project_worker).transpose()?,
|
||||||
|
"merge_requests": merges,
|
||||||
|
"evidence": root.get("evidence").map(project_evidence).transpose()?,
|
||||||
|
"actions": root.get("action_eligibility").filter(|value| !value.is_null()).map(project_actions).transpose()?,
|
||||||
|
"latest_event_ref": latest_event_ref,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn project_ticket_history(value: Value) -> Result<Value, String> {
|
||||||
|
let root = object(&value, "Ticket history response")?;
|
||||||
|
let thread = array_field(root, "events")?
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.get("is_initial") != Some(&Value::Bool(true)))
|
||||||
|
.map(project_ticket_event)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"ticket": resource_ref(root, "resource_key", "T-")?,
|
||||||
|
"thread": thread,
|
||||||
|
"event_page": project_event_page(root.get("event_page").ok_or("missing event_page")?)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn project_ticket_post(value: Value) -> Result<Value, String> {
|
||||||
|
let post = project_ticket_event(&value)?;
|
||||||
|
if post.body_truncated || post.body.is_none() {
|
||||||
|
return Err(
|
||||||
|
"Full-post read returned an absent or truncated body; do not use it for edit CAS"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serde_json::to_value(post).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||||
let root = object(&value, "Ticket detail response")?;
|
let root = object(&value, "Ticket detail response")?;
|
||||||
let current_worker = root
|
let current_worker = root
|
||||||
@@ -354,8 +435,6 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
ticket: resource_ref(root, "resource_key", "T-")?,
|
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||||
content_digest: string_field(root, "content_digest")?,
|
content_digest: string_field(root, "content_digest")?,
|
||||||
title: string_field(root, "title")?,
|
title: string_field(root, "title")?,
|
||||||
body: string_field(root, "body")?,
|
|
||||||
body_truncated: bool_field(root, "body_truncated")?,
|
|
||||||
initial_post: project_ticket_event(
|
initial_post: project_ticket_event(
|
||||||
root.get("initial_post")
|
root.get("initial_post")
|
||||||
.ok_or("Ticket detail response is missing initial_post")?,
|
.ok_or("Ticket detail response is missing initial_post")?,
|
||||||
@@ -364,9 +443,15 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
created_by: optional_string(root, "created_by")?,
|
created_by: optional_string(root, "created_by")?,
|
||||||
state: typed_field(root, "state")?,
|
state: typed_field(root, "state")?,
|
||||||
closure_operation_key: typed_field(root, "closure_operation_key")?,
|
closure_operation_key: typed_field(root, "closure_operation_key")?,
|
||||||
current_closure: typed_field(root, "current_closure")?,
|
current_closure: project_current_closure(root)?,
|
||||||
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
||||||
closure_history: typed_field(root, "closure_history")?,
|
closure_history: typed_field::<Vec<ticket::TicketCaseHistoryEntry>>(
|
||||||
|
root,
|
||||||
|
"closure_history",
|
||||||
|
)?
|
||||||
|
.into_iter()
|
||||||
|
.map(closure_metadata)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
assignment_tracking: project_assignment_tracking(root)?,
|
assignment_tracking: project_assignment_tracking(root)?,
|
||||||
tags: project_tags(root)?,
|
tags: project_tags(root)?,
|
||||||
readiness: optional_string(root, "readiness")?,
|
readiness: optional_string(root, "readiness")?,
|
||||||
@@ -375,6 +460,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
updated_at: optional_string(root, "updated_at")?,
|
updated_at: optional_string(root, "updated_at")?,
|
||||||
thread: array_field(root, "events")?
|
thread: array_field(root, "events")?
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|event| event.get("is_initial") != Some(&Value::Bool(true)))
|
||||||
.map(project_ticket_event)
|
.map(project_ticket_event)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
relations: project_relations(root.get("relations"))?,
|
relations: project_relations(root.get("relations"))?,
|
||||||
@@ -392,11 +478,6 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
.iter()
|
.iter()
|
||||||
.map(project_merge_request)
|
.map(project_merge_request)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
merge_request: root
|
|
||||||
.get("merge_request")
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
.map(project_merge_request)
|
|
||||||
.transpose()?,
|
|
||||||
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
||||||
actions: root
|
actions: root
|
||||||
.get("action_eligibility")
|
.get("action_eligibility")
|
||||||
@@ -919,7 +1000,18 @@ mod tests {
|
|||||||
assert_eq!(projected["state"], "open");
|
assert_eq!(projected["state"], "open");
|
||||||
assert_eq!(projected["closure_operation_key"], "close-1");
|
assert_eq!(projected["closure_operation_key"], "close-1");
|
||||||
assert_eq!(projected["current_closure"], Value::Null);
|
assert_eq!(projected["current_closure"], Value::Null);
|
||||||
assert_eq!(projected["closure_history"], history);
|
assert_eq!(
|
||||||
|
projected["closure_history"],
|
||||||
|
json!(
|
||||||
|
history
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(closure_metadata)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
assert_eq!(projected["legacy_handoff"], legacy);
|
assert_eq!(projected["legacy_handoff"], legacy);
|
||||||
assert_eq!(projected["tags"], response["tags"]);
|
assert_eq!(projected["tags"], response["tags"]);
|
||||||
assert_eq!(projected["content_digest"], "content-digest");
|
assert_eq!(projected["content_digest"], "content-digest");
|
||||||
@@ -1191,7 +1283,8 @@ mod tests {
|
|||||||
.expect("source evidence must project");
|
.expect("source evidence must project");
|
||||||
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
|
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
|
||||||
|
|
||||||
for merge in [&projected["merge_requests"][0], &projected["merge_request"]] {
|
assert!(projected.get("merge_request").is_none());
|
||||||
|
for merge in [&projected["merge_requests"][0]] {
|
||||||
assert_eq!(merge["subject_ref"], json!(subject_ref));
|
assert_eq!(merge["subject_ref"], json!(subject_ref));
|
||||||
assert_eq!(merge["source_ref_observation"], observation);
|
assert_eq!(merge["source_ref_observation"], observation);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1378,9 +1471,14 @@ mod tests {
|
|||||||
"current_closure",
|
"current_closure",
|
||||||
"legacy_handoff",
|
"legacy_handoff",
|
||||||
] {
|
] {
|
||||||
assert_eq!(projected_detail[field], detail[field], "detail {field}");
|
let expected = if field == "current_closure" {
|
||||||
|
closure_metadata(&detail[field]).unwrap()
|
||||||
|
} else {
|
||||||
|
detail[field].clone()
|
||||||
|
};
|
||||||
|
assert_eq!(projected_detail[field], expected, "detail {field}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected_query["tickets"][0][field], detail[field],
|
projected_query["tickets"][0][field], expected,
|
||||||
"query {field}"
|
"query {field}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1392,10 +1490,22 @@ mod tests {
|
|||||||
assert!(actual.get("workspace_id").is_none());
|
assert!(actual.get("workspace_id").is_none());
|
||||||
assert!(actual.get("ticket_id").is_none());
|
assert!(actual.get("ticket_id").is_none());
|
||||||
}
|
}
|
||||||
assert_eq!(projected_detail["closure_history"], history);
|
assert_eq!(
|
||||||
|
projected_detail["closure_history"],
|
||||||
|
json!(
|
||||||
|
history
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(closure_metadata)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
assert_eq!(projected_detail["content_digest"], "content-1");
|
assert_eq!(projected_detail["content_digest"], "content-1");
|
||||||
assert_eq!(projected_detail["created_by"], "workspace-user:creator");
|
assert_eq!(projected_detail["created_by"], "workspace-user:creator");
|
||||||
assert_eq!(projected_detail["body_truncated"], false);
|
assert_eq!(projected_detail["initial_post"]["body_truncated"], false);
|
||||||
|
assert!(projected_detail.get("body").is_none());
|
||||||
for initial in [true, false] {
|
for initial in [true, false] {
|
||||||
let input = post_fixture(initial, "The same post contract");
|
let input = post_fixture(initial, "The same post contract");
|
||||||
let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap();
|
let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap();
|
||||||
@@ -1488,6 +1598,93 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_views_keep_prose_once_and_summary_independent_of_prose_and_history() {
|
||||||
|
let body = "unique-long-requirement-".repeat(600);
|
||||||
|
let post = post_fixture(true, &body);
|
||||||
|
let closure = json!({"operation_key":"close", "reason":"completed", "explanation":"closure prose", "references":[], "author":"user", "at":"today"});
|
||||||
|
let tracking = json!({"workspace_id":"ws", "ticket_id":"internal", "assignment_id":"assignment", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"user", "assigned_at":"today", "release":null, "current":true, "last_event_id":"handling-event", "updated_at":"today", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"prose", "progress":null, "next_action":"prose", "result":null}});
|
||||||
|
let merge = json!({"merge_request_id":"MR-1", "repository_key":"main", "state":"open", "selector_from":"topic", "selector_to":"main", "review_status":"request_changes", "current_subject_ref":"sha1", "source_ref_observation":{"status":"observed"}, "integration_evidence_error":null, "review_excerpt":"review prose"});
|
||||||
|
let mut input = json!({
|
||||||
|
"resource_key":"T-848", "title":"Views", "body":body, "body_truncated":false, "initial_post":post,
|
||||||
|
"content_digest":"content", "state":"closed", "closure_operation_key":"close", "current_closure":closure,
|
||||||
|
"closure_history":[], "legacy_handoff":null, "targets":[], "tags":[], "assignment_tracking":[tracking],
|
||||||
|
"events":[post, post_fixture(false, "discussion")], "assignments":[], "linked_objectives":[],
|
||||||
|
"implementation_reports":[], "merge_requests":[merge.clone()], "merge_request":merge,
|
||||||
|
"event_page":{"next_cursor":"stable-cursor", "has_more":true}
|
||||||
|
});
|
||||||
|
let detail = serde_json::to_value(project_ticket_detail(input.clone()).unwrap()).unwrap();
|
||||||
|
assert_eq!(detail.to_string().matches(&body).count(), 1);
|
||||||
|
assert!(detail.get("body").is_none());
|
||||||
|
assert!(detail.get("merge_request").is_none());
|
||||||
|
assert_eq!(detail["thread"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(detail["initial_post"]["event_ref"], "post-initial");
|
||||||
|
assert_eq!(detail["initial_post"]["body_digest"], "a".repeat(64));
|
||||||
|
assert!(detail["current_closure"].get("explanation").is_none());
|
||||||
|
let history = project_ticket_history(input.clone()).unwrap();
|
||||||
|
assert_eq!(history.as_object().unwrap().len(), 3);
|
||||||
|
assert_eq!(history["event_page"]["next_cursor"], "stable-cursor");
|
||||||
|
let summary = project_ticket_status(input.clone()).unwrap();
|
||||||
|
// Reconstruct the removed model-facing aliases on this same fixture.
|
||||||
|
let mut duplicated = detail.clone();
|
||||||
|
duplicated["body"] = detail["initial_post"]["body"].clone();
|
||||||
|
duplicated["body_truncated"] = detail["initial_post"]["body_truncated"].clone();
|
||||||
|
duplicated["merge_request"] = detail["merge_requests"][0].clone();
|
||||||
|
duplicated["thread"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(0, detail["initial_post"].clone());
|
||||||
|
assert_eq!(duplicated.to_string().matches(&body).count(), 3);
|
||||||
|
assert!(duplicated.to_string().len() > detail.to_string().len());
|
||||||
|
let mut changed = input.clone();
|
||||||
|
changed["content_digest"] = json!("new-requirements-digest");
|
||||||
|
changed["merge_requests"][0]["current_subject_ref"] = json!("sha2");
|
||||||
|
changed["merge_requests"][0]["review_status"] = json!("pending");
|
||||||
|
let changed = project_ticket_status(changed).unwrap();
|
||||||
|
assert_ne!(summary["content_digest"], changed["content_digest"]);
|
||||||
|
assert_ne!(
|
||||||
|
summary["merge_requests"][0]["subject_ref"],
|
||||||
|
changed["merge_requests"][0]["subject_ref"]
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
summary["merge_requests"][0]["review_status"],
|
||||||
|
changed["merge_requests"][0]["review_status"]
|
||||||
|
);
|
||||||
|
assert_eq!(summary["content_digest"], "content");
|
||||||
|
assert_eq!(summary["closure_operation_key"], "close");
|
||||||
|
assert_eq!(summary["assignment_tracking"][0]["status"], "waiting");
|
||||||
|
assert_eq!(
|
||||||
|
summary["assignment_tracking"][0]["last_event_id"],
|
||||||
|
"handling-event"
|
||||||
|
);
|
||||||
|
assert_eq!(summary["merge_requests"][0]["subject_ref"], "sha1");
|
||||||
|
assert_eq!(
|
||||||
|
summary["merge_requests"][0]["review_status"],
|
||||||
|
"request_changes"
|
||||||
|
);
|
||||||
|
let huge = "全文🦀".repeat(100_000);
|
||||||
|
input["body"] = json!(huge);
|
||||||
|
input["initial_post"]["body"] = json!(huge);
|
||||||
|
input["current_closure"]["explanation"] = json!(huge);
|
||||||
|
input["assignment_tracking"][0]["tracking"]["waiting_reason"] = json!(huge);
|
||||||
|
input["merge_requests"][0]["review_excerpt"] = json!(huge);
|
||||||
|
input["closure_history"] = json!(vec![json!({"explanation":huge}); 20]);
|
||||||
|
input["implementation_reports"] = json!(vec![json!({"excerpt":huge}); 20]);
|
||||||
|
input["events"] = json!(vec![post_fixture(false, "history prose"); 1000]);
|
||||||
|
let larger = project_ticket_status(input).unwrap();
|
||||||
|
assert_eq!(summary, larger);
|
||||||
|
eprintln!(
|
||||||
|
"Ticket workspace bytes: duplicated fixture={}, detail={}, summary={} (identical with large prose and 1000 events)",
|
||||||
|
duplicated.to_string().len(),
|
||||||
|
detail.to_string().len(),
|
||||||
|
summary.to_string().len()
|
||||||
|
);
|
||||||
|
let mut full = post_fixture(false, &huge);
|
||||||
|
assert_eq!(project_ticket_post(full.clone()).unwrap()["body"], huge);
|
||||||
|
full["body_truncated"] = json!(true);
|
||||||
|
assert!(project_ticket_post(full).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
||||||
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ use crate::wip::{
|
|||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
|
|
||||||
use super::resource_projection::{project_ticket_detail, project_ticket_query};
|
use super::resource_projection::{
|
||||||
|
project_ticket_detail, project_ticket_history, project_ticket_post, project_ticket_query,
|
||||||
|
project_ticket_status,
|
||||||
|
};
|
||||||
|
use ticket::tool::ShowTicketView;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum WorkspaceTicketReadKind {
|
enum WorkspaceTicketReadKind {
|
||||||
@@ -102,7 +106,7 @@ impl WorkspaceTicketReadKind {
|
|||||||
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
||||||
}
|
}
|
||||||
Self::Show => {
|
Self::Show => {
|
||||||
"Show one authoritative Workspace Ticket with its ordered implementation targets (repository_key, ref_selector, access), content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence. Read targets here before choosing a work repository; a null selector remains unspecified, not an inferred default."
|
"Read one authoritative Workspace Ticket. Use view=summary for current state/CAS, targets, delegation identities/status and MR evidence without prose/history; detail (default) for canonical initial_post and bounded discussion; history with event_cursor for only thread/page metadata; post with an observed event_ref for the exact full post. Never use truncated text for edit CAS or guess event IDs. Read targets before choosing a repository; a null selector remains unspecified. Summary alone is not approval or completion authority."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,9 +231,14 @@ struct WorkspaceQueryTicketInput {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
struct WorkspaceShowTicketInput {
|
struct WorkspaceShowTicketInput {
|
||||||
|
/// summary omits prose/history; detail is default; history returns only a thread page; post returns the exact full post up to 1 MiB (larger posts fail, never truncate).
|
||||||
|
#[serde(default)]
|
||||||
|
view: ShowTicketView,
|
||||||
|
/// Exact observed event_ref, required only for view=post. Never a display ordinal.
|
||||||
|
event_ref: Option<String>,
|
||||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||||
id: String,
|
id: String,
|
||||||
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
|
/// Most-recent thread entries to return, bounded by the Backend to 1..=100.
|
||||||
event_limit: Option<usize>,
|
event_limit: Option<usize>,
|
||||||
/// Opaque event cursor returned by a prior ShowTicket page.
|
/// Opaque event cursor returned by a prior ShowTicket page.
|
||||||
event_cursor: Option<String>,
|
event_cursor: Option<String>,
|
||||||
@@ -251,6 +260,8 @@ impl Tool for WorkspaceTicketReadTool {
|
|||||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||||
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
|
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
|
||||||
})?;
|
})?;
|
||||||
|
let mut view = ShowTicketView::Detail;
|
||||||
|
let mut method = WorkspaceRequestMethod::Post;
|
||||||
let (path, body) = match self.kind {
|
let (path, body) = match self.kind {
|
||||||
WorkspaceTicketReadKind::Query => {
|
WorkspaceTicketReadKind::Query => {
|
||||||
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
|
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
|
||||||
@@ -276,22 +287,41 @@ impl Tool for WorkspaceTicketReadTool {
|
|||||||
"ShowTicket.id must not be empty".into(),
|
"ShowTicket.id must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim());
|
view = input.view;
|
||||||
let body = json!({
|
view.validate(input.event_cursor.as_deref(), input.event_ref.as_deref())
|
||||||
"event_limit": input.event_limit,
|
.map_err(ToolError::InvalidArgument)?;
|
||||||
"event_cursor": input.event_cursor,
|
let id =
|
||||||
});
|
WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::from(input.id.trim()));
|
||||||
(path, body)
|
if let Some(event_ref) = input.event_ref {
|
||||||
|
method = WorkspaceRequestMethod::Get;
|
||||||
|
let event_ref =
|
||||||
|
WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::from(event_ref));
|
||||||
|
(
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}"),
|
||||||
|
Value::Null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let path = format!("/api/w/{workspace_id}/tickets/{id}/show");
|
||||||
|
let body = json!({
|
||||||
|
"event_limit": if view == ShowTicketView::Summary { Some(1) } else { input.event_limit },
|
||||||
|
"event_cursor": input.event_cursor,
|
||||||
|
});
|
||||||
|
(path, body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.execute(WorkspaceRequest::json(
|
.execute(if method == WorkspaceRequestMethod::Get {
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequest::get(path)
|
||||||
path,
|
} else {
|
||||||
serde_json::to_string(&body)
|
WorkspaceRequest::json(
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
method,
|
||||||
))
|
path,
|
||||||
|
serde_json::to_string(&body)
|
||||||
|
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
||||||
|
)
|
||||||
|
})
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
if !response.is_success() {
|
if !response.is_success() {
|
||||||
return Err(ToolError::ExecutionFailed(ticket_api_error_message(
|
return Err(ToolError::ExecutionFailed(ticket_api_error_message(
|
||||||
@@ -308,9 +338,20 @@ impl Tool for WorkspaceTicketReadTool {
|
|||||||
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
||||||
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
),
|
),
|
||||||
WorkspaceTicketReadKind::Show => serde_json::to_string(
|
WorkspaceTicketReadKind::Show => match view {
|
||||||
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
ShowTicketView::Detail => serde_json::to_string(
|
||||||
),
|
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
|
),
|
||||||
|
ShowTicketView::Summary => serde_json::to_string(
|
||||||
|
&project_ticket_status(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
|
),
|
||||||
|
ShowTicketView::History => serde_json::to_string(
|
||||||
|
&project_ticket_history(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||||
|
),
|
||||||
|
ShowTicketView::Post => serde_json::to_string(
|
||||||
|
&json!({"post": project_ticket_post(response_value).map_err(ToolError::ExecutionFailed)?}),
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
@@ -2894,6 +2935,127 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn show_views_share_tool_and_route_bound_wip_wiring() {
|
||||||
|
for view in ["summary", "detail", "history", "post"] {
|
||||||
|
let mut detail = target_detail_fixture(
|
||||||
|
json!([{"repository_key":"main", "ref_selector":null, "access":"read_write"}]),
|
||||||
|
);
|
||||||
|
let unicode = "全文🦀é\n".repeat(20_000);
|
||||||
|
detail["event_page"] = json!({"next_cursor":"opaque-before", "has_more":true});
|
||||||
|
let mut post = detail["initial_post"].clone();
|
||||||
|
post["event_ref"] = json!("persisted:id/with unicode界");
|
||||||
|
post["body"] = json!(unicode);
|
||||||
|
let response = if view == "post" { post.clone() } else { detail };
|
||||||
|
let client = Arc::new(DecisionClient {
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
response: crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: response.to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let (meta, tool) =
|
||||||
|
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||||
|
assert!(meta.input_schema["properties"]["view"].is_object());
|
||||||
|
assert!(meta.input_schema["properties"]["event_ref"].is_object());
|
||||||
|
let mut input = json!({"id":"T-739", "view":view});
|
||||||
|
if view == "post" {
|
||||||
|
input["event_ref"] = post["event_ref"].clone();
|
||||||
|
}
|
||||||
|
if view == "history" {
|
||||||
|
input["event_cursor"] = json!("opaque-before");
|
||||||
|
}
|
||||||
|
let direct = ticket_tool_output_json(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("Ticket {view} returned invalid JSON"));
|
||||||
|
let tools =
|
||||||
|
native_ticket_tools(client.clone(), TicketFeatureAccess::read_only()).unwrap();
|
||||||
|
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||||
|
let read = descriptor
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.find(|op| op.name == "read")
|
||||||
|
.unwrap();
|
||||||
|
assert!(read.parameters.iter().any(|param| param.name == "view"));
|
||||||
|
assert!(
|
||||||
|
read.parameters
|
||||||
|
.iter()
|
||||||
|
.any(|param| param.name == "event_ref")
|
||||||
|
);
|
||||||
|
assert!(!read.parameters.iter().any(|param| param.name == "id"));
|
||||||
|
let resolver = TicketItemResolver {
|
||||||
|
tools: operation_map(tools),
|
||||||
|
permissions: None,
|
||||||
|
collection_route: "/tickets".into(),
|
||||||
|
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||||
|
};
|
||||||
|
input.as_object_mut().unwrap().remove("id");
|
||||||
|
let arguments = input
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
|
||||||
|
.collect();
|
||||||
|
let bound = resolver.resolve("T-739").unwrap();
|
||||||
|
let output = bound
|
||||||
|
.handler
|
||||||
|
.call(
|
||||||
|
"read",
|
||||||
|
&arguments,
|
||||||
|
WipCallContext {
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "reader".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP {view} failed"));
|
||||||
|
assert_eq!(wip_to_json(&output.value).unwrap(), direct);
|
||||||
|
match view {
|
||||||
|
"post" => {
|
||||||
|
assert_eq!(direct["post"]["body"], unicode);
|
||||||
|
assert_eq!(direct["post"]["body_truncated"], false);
|
||||||
|
}
|
||||||
|
"history" => {
|
||||||
|
assert_eq!(direct.as_object().unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
"summary" => {
|
||||||
|
assert!(direct.get("initial_post").is_none());
|
||||||
|
assert_eq!(direct["content_digest"], "observed-content");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
assert_eq!(direct["initial_post"]["body"], "Requirements");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[0].method, requests[1].method);
|
||||||
|
assert_eq!(requests[0].path, requests[1].path);
|
||||||
|
assert_eq!(requests[0].body, requests[1].body);
|
||||||
|
if view == "post" {
|
||||||
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].path,
|
||||||
|
"/api/w/workspace/tickets/T-739/posts/persisted%3Aid%2Fwith%20unicode%E7%95%8C"
|
||||||
|
);
|
||||||
|
assert!(requests[0].body.is_none());
|
||||||
|
} else {
|
||||||
|
let body: Value =
|
||||||
|
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(body.get("view").is_none());
|
||||||
|
assert!(body.get("event_ref").is_none());
|
||||||
|
if view == "summary" {
|
||||||
|
assert_eq!(body["event_limit"], 1);
|
||||||
|
}
|
||||||
|
if view == "history" {
|
||||||
|
assert_eq!(body["event_cursor"], "opaque-before");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() {
|
async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() {
|
||||||
for malformed in [
|
for malformed in [
|
||||||
|
|||||||
@@ -18752,6 +18752,130 @@ fn integrated_merge_refs(
|
|||||||
Some((source, target))
|
Some((source, target))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_request_event_reference(
|
||||||
|
event: &merge_request::MergeRequestThreadEvent,
|
||||||
|
) -> server_api::MergeRequestEventReference {
|
||||||
|
use merge_request::MergeRequestThreadEvent as Event;
|
||||||
|
let event_id = match event {
|
||||||
|
Event::ReviewRequested(event) => &event.event_id,
|
||||||
|
Event::Review(event) => &event.event_id,
|
||||||
|
Event::ReviewRevoked(event) => &event.event_id,
|
||||||
|
Event::ReviewCancelled(event) => &event.event_id,
|
||||||
|
Event::Comment(event) => &event.event_id,
|
||||||
|
Event::Merge(event) => &event.event_id,
|
||||||
|
};
|
||||||
|
server_api::MergeRequestEventReference {
|
||||||
|
event_id: event_id.clone(),
|
||||||
|
sequence: event.sequence(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_request_status_summary(
|
||||||
|
mr: &merge_request::MergeRequest,
|
||||||
|
source: &server_api::MergeRequestRefResponse,
|
||||||
|
mut readiness: server_api::MergeRequestReadinessResponse,
|
||||||
|
) -> server_api::MergeRequestStatusSummary {
|
||||||
|
use merge_request::MergeRequestThreadEvent as Event;
|
||||||
|
// Never trust a ref retained alongside an unknown/invalid observation.
|
||||||
|
let subject = (source.status == "known")
|
||||||
|
.then_some(source.resolved_ref.as_deref())
|
||||||
|
.flatten();
|
||||||
|
let review = subject.and_then(|subject| {
|
||||||
|
if mr.state == merge_request::MergeRequestState::Merged {
|
||||||
|
mr.integration_approval().ok()
|
||||||
|
} else {
|
||||||
|
mr.effective_review(subject)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let pending = subject.is_some_and(|subject| {
|
||||||
|
mr.thread.iter().any(|event| {
|
||||||
|
let Event::ReviewRequested(request) = event else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
request.subject_ref == subject
|
||||||
|
&& !mr.thread.iter().any(|event| match event {
|
||||||
|
Event::Review(review) => review.request_event_id == request.event_id,
|
||||||
|
Event::ReviewCancelled(cancelled) => {
|
||||||
|
cancelled.request_event_id == request.event_id
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let review_status = match (subject, review) {
|
||||||
|
(None, _) => "unknown",
|
||||||
|
(_, Some(review)) => match review.decision {
|
||||||
|
merge_request::ReviewDecision::Approve => "approved",
|
||||||
|
merge_request::ReviewDecision::RequestChanges => "request_changes",
|
||||||
|
},
|
||||||
|
(_, None) if pending => "pending",
|
||||||
|
_ => "none",
|
||||||
|
};
|
||||||
|
// Readiness performs a second store read. A concurrent review/revocation must
|
||||||
|
// not turn this older summary snapshot into ready evidence for another review.
|
||||||
|
let snapshot_review = review.cloned().map(public_review_event);
|
||||||
|
if readiness.subject_ref.as_deref() != subject || readiness.review != snapshot_review {
|
||||||
|
readiness.ready = false;
|
||||||
|
readiness.blockers.push("readiness_evidence_changed".into());
|
||||||
|
}
|
||||||
|
// Retain readiness's established checks, but not a duplicate review body.
|
||||||
|
readiness.review = None;
|
||||||
|
server_api::MergeRequestStatusSummary {
|
||||||
|
review_status: review_status.into(),
|
||||||
|
review: review.map(|review| server_api::MergeRequestReviewSummary {
|
||||||
|
event: server_api::MergeRequestEventReference {
|
||||||
|
event_id: review.event_id.clone(),
|
||||||
|
sequence: review.sequence,
|
||||||
|
},
|
||||||
|
request_event_id: review.request_event_id.clone(),
|
||||||
|
subject_ref: review.subject_ref.clone(),
|
||||||
|
ticket_content_digest: review.ticket_content_digest.clone(),
|
||||||
|
decision: public_review_decision(review.decision),
|
||||||
|
findings_count: review.findings.len(),
|
||||||
|
}),
|
||||||
|
latest_review_subject_ref: mr.thread.iter().rev().find_map(|event| match event {
|
||||||
|
Event::ReviewRequested(event) => Some(event.subject_ref.clone()),
|
||||||
|
Event::Review(event) => Some(event.subject_ref.clone()),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
latest_event: mr.thread.last().map(merge_request_event_reference),
|
||||||
|
thread_event_count: mr.thread.len(),
|
||||||
|
readiness,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page the same complete snapshot used for the canonical summary. A second store
|
||||||
|
// read could include a revocation newer than the summary (or miss one it includes).
|
||||||
|
fn project_merge_request_thread(
|
||||||
|
mr: &mut merge_request::MergeRequest,
|
||||||
|
query: &server_api::MergeRequestThreadQuery,
|
||||||
|
) -> server_api::MergeRequestEventPage {
|
||||||
|
// Preserve legacy HTTP callers; explicit compact/detail reads use the smaller default.
|
||||||
|
let default_limit = if query.view.is_none() { 100 } else { 20 };
|
||||||
|
let limit = query.limit.unwrap_or(default_limit).clamp(1, 200);
|
||||||
|
if query.view == Some(server_api::MergeRequestReadView::Summary) {
|
||||||
|
let has_more = !mr.thread.is_empty();
|
||||||
|
mr.thread.clear();
|
||||||
|
return server_api::MergeRequestEventPage {
|
||||||
|
omitted: true,
|
||||||
|
has_more,
|
||||||
|
next_after: None,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut events = std::mem::take(&mut mr.thread)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| query.after.is_none_or(|after| event.sequence() > after));
|
||||||
|
mr.thread = events.by_ref().take(limit).collect();
|
||||||
|
let has_more = events.next().is_some();
|
||||||
|
server_api::MergeRequestEventPage {
|
||||||
|
omitted: false,
|
||||||
|
has_more,
|
||||||
|
next_after: has_more.then(|| mr.thread.last().unwrap().sequence()),
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)>,
|
||||||
@@ -18765,12 +18889,16 @@ async fn scoped_show_merge_request(
|
|||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||||
let integrated_refs = integrated_merge_refs(&mr);
|
let integrated_refs = integrated_merge_refs(&mr);
|
||||||
mr.thread = store.thread_page_by_id(
|
if query.view == Some(server_api::MergeRequestReadView::Summary)
|
||||||
&workspace_id,
|
&& (query.after.is_some() || query.limit.is_some())
|
||||||
&merge_request_id,
|
{
|
||||||
query.after,
|
return Err(
|
||||||
query.limit.unwrap_or(100),
|
Error::MergeRequest(merge_request::MergeRequestError::Validation(
|
||||||
)?;
|
"after/limit require view=detail".into(),
|
||||||
|
))
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
let ticket_id = mr.ticket_ids.first().ok_or_else(|| {
|
let ticket_id = mr.ticket_ids.first().ok_or_else(|| {
|
||||||
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
Error::MergeRequest(merge_request::MergeRequestError::Conflict(
|
||||||
"Merge Request has no linked Ticket".into(),
|
"Merge Request has no linked Ticket".into(),
|
||||||
@@ -18824,6 +18952,38 @@ async fn scoped_show_merge_request(
|
|||||||
};
|
};
|
||||||
(source, target)
|
(source, target)
|
||||||
};
|
};
|
||||||
|
let mut report = store.readiness(merge_request::ReadinessCheck {
|
||||||
|
merge_request_id: mr.merge_request_id.clone(),
|
||||||
|
ticket_id: ticket_id.clone(),
|
||||||
|
current_subject_ref: (source.status == "known")
|
||||||
|
.then(|| source.resolved_ref.clone())
|
||||||
|
.flatten(),
|
||||||
|
auth: merge_request::MergeRequestAuth {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
repository_id: mr.repository_id.clone(),
|
||||||
|
runtime_id: String::new(),
|
||||||
|
worker_id: String::new(),
|
||||||
|
assignment_id: String::new(),
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
if let Some(diagnostic) = &source.diagnostic {
|
||||||
|
let blocker = source_ref_readiness_blocker(&diagnostic.code).to_string();
|
||||||
|
if !report.blockers.contains(&blocker) {
|
||||||
|
report.blockers.push(blocker);
|
||||||
|
}
|
||||||
|
report.ready = false;
|
||||||
|
}
|
||||||
|
let summary = merge_request_status_summary(
|
||||||
|
&mr,
|
||||||
|
&source,
|
||||||
|
server_api::MergeRequestReadinessResponse {
|
||||||
|
ready: report.ready,
|
||||||
|
blockers: report.blockers,
|
||||||
|
subject_ref: report.subject_ref,
|
||||||
|
review: report.review.map(public_review_event),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let event_page = project_merge_request_thread(&mut mr, &query);
|
||||||
let linked_tickets = mr
|
let linked_tickets = mr
|
||||||
.ticket_ids
|
.ticket_ids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -18844,6 +19004,9 @@ async fn scoped_show_merge_request(
|
|||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
linked_tickets,
|
linked_tickets,
|
||||||
|
view: query.view.unwrap_or_default(),
|
||||||
|
summary,
|
||||||
|
event_page,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -65940,6 +66103,321 @@ mod tests {
|
|||||||
assert!(!projected.contains("different-runtime"));
|
assert!(!projected.contains("different-runtime"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mr_read_fixture() -> merge_request::MergeRequest {
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"workspace_id": TEST_WORKSPACE_ID, "merge_request_id": "mr-read",
|
||||||
|
"repository_id": "repo", "state": "open", "selector_from": "work/read",
|
||||||
|
"selector_to": "develop", "ticket_ids": ["ticket"],
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"thread": [
|
||||||
|
{"kind": "review_requested", "event_id": "request-1", "sequence": 1,
|
||||||
|
"subject_ref": "source-1", "ticket_content_digest": "digest",
|
||||||
|
"ticket_merge_request_subjects": [],
|
||||||
|
"requested_by": {"runtime_id": "runtime", "worker_id": "coder"},
|
||||||
|
"reviewer": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"},
|
||||||
|
{"kind": "review", "event_id": "approval-1", "sequence": 2,
|
||||||
|
"request_event_id": "request-1", "subject_ref": "source-1",
|
||||||
|
"ticket_content_digest": "digest", "ticket_merge_request_subjects": [],
|
||||||
|
"decision": "approve", "body": "Exact review body", "findings": [],
|
||||||
|
"reviewer": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"},
|
||||||
|
{"kind": "comment", "event_id": "comment-1", "sequence": 3,
|
||||||
|
"body": "Later history", "author": {"runtime_id": "runtime", "worker_id": "coder"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mr_read_source() -> server_api::MergeRequestRefResponse {
|
||||||
|
server_api::MergeRequestRefResponse {
|
||||||
|
status: "known".into(),
|
||||||
|
resolved_ref: Some("source-1".into()),
|
||||||
|
observed_at: "2026-01-01T00:00:00Z".into(),
|
||||||
|
diagnostic: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mr_read_summary(mr: &merge_request::MergeRequest) -> server_api::MergeRequestStatusSummary {
|
||||||
|
merge_request_status_summary(
|
||||||
|
mr,
|
||||||
|
&mr_read_source(),
|
||||||
|
server_api::MergeRequestReadinessResponse {
|
||||||
|
ready: false,
|
||||||
|
blockers: vec!["fixture readiness".into()],
|
||||||
|
subject_ref: Some("source-1".into()),
|
||||||
|
review: mr
|
||||||
|
.effective_review("source-1")
|
||||||
|
.cloned()
|
||||||
|
.map(public_review_event),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_http_default_page_preserves_legacy_limit() {
|
||||||
|
let mut complete = mr_read_fixture();
|
||||||
|
let merge_request::MergeRequestThreadEvent::Comment(comment) =
|
||||||
|
complete.thread.pop().unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
complete.thread = (1..=205)
|
||||||
|
.map(|sequence| {
|
||||||
|
let mut comment = comment.clone();
|
||||||
|
comment.sequence = sequence;
|
||||||
|
comment.event_id = format!("comment-{sequence}");
|
||||||
|
merge_request::MergeRequestThreadEvent::Comment(comment)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (view, limit, expected) in [
|
||||||
|
(None, None, 100),
|
||||||
|
(Some(server_api::MergeRequestReadView::Detail), None, 20),
|
||||||
|
(None, Some(7), 7),
|
||||||
|
(
|
||||||
|
Some(server_api::MergeRequestReadView::Detail),
|
||||||
|
Some(500),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut mr = complete.clone();
|
||||||
|
let page = project_merge_request_thread(
|
||||||
|
&mut mr,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
view,
|
||||||
|
limit,
|
||||||
|
after: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(mr.thread.len(), expected);
|
||||||
|
assert_eq!(page.limit, expected);
|
||||||
|
assert!(page.has_more && !page.omitted);
|
||||||
|
assert_eq!(page.next_after, Some(expected as u64));
|
||||||
|
}
|
||||||
|
let page = project_merge_request_thread(
|
||||||
|
&mut complete,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
after: Some(200),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(complete.thread.len(), 5);
|
||||||
|
assert!(!page.has_more);
|
||||||
|
assert_eq!(page.next_after, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_readiness_requires_same_review_snapshot() {
|
||||||
|
let mut mr = mr_read_fixture();
|
||||||
|
let report = server_api::MergeRequestReadinessResponse {
|
||||||
|
ready: true,
|
||||||
|
blockers: vec![],
|
||||||
|
subject_ref: Some("source-1".into()),
|
||||||
|
review: mr
|
||||||
|
.effective_review("source-1")
|
||||||
|
.cloned()
|
||||||
|
.map(public_review_event),
|
||||||
|
};
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), report.clone());
|
||||||
|
assert!(summary.readiness.ready);
|
||||||
|
assert!(summary.readiness.blockers.is_empty());
|
||||||
|
assert!(
|
||||||
|
summary.readiness.review.is_none(),
|
||||||
|
"exact evidence is checked before body omission"
|
||||||
|
);
|
||||||
|
assert_eq!(summary.review.unwrap().event.event_id, "approval-1");
|
||||||
|
|
||||||
|
let mut changed = report.clone();
|
||||||
|
changed.review.as_mut().unwrap().event_id = "new-approval".into();
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), changed);
|
||||||
|
assert!(
|
||||||
|
!summary.readiness.ready,
|
||||||
|
"different approval cannot validate the older snapshot"
|
||||||
|
);
|
||||||
|
assert_eq!(summary.readiness.blockers, ["readiness_evidence_changed"]);
|
||||||
|
assert_eq!(summary.review.unwrap().event.event_id, "approval-1");
|
||||||
|
|
||||||
|
let mut revoked = report.clone();
|
||||||
|
revoked.ready = false;
|
||||||
|
revoked.review = None;
|
||||||
|
revoked.blockers.push("review revoked".into());
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), revoked);
|
||||||
|
assert!(!summary.readiness.ready);
|
||||||
|
assert_eq!(
|
||||||
|
summary.readiness.blockers,
|
||||||
|
["review revoked", "readiness_evidence_changed"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut moved = report.clone();
|
||||||
|
moved.subject_ref = Some("source-2".into());
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), moved);
|
||||||
|
assert!(!summary.readiness.ready);
|
||||||
|
assert_eq!(summary.readiness.subject_ref.as_deref(), Some("source-2"));
|
||||||
|
assert_eq!(summary.review.unwrap().subject_ref, "source-1");
|
||||||
|
|
||||||
|
let merge_request::MergeRequestThreadEvent::Review(review) = &mut mr.thread[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
review.decision = merge_request::ReviewDecision::RequestChanges;
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), report.clone());
|
||||||
|
assert_eq!(summary.review_status, "request_changes");
|
||||||
|
assert!(
|
||||||
|
!summary.readiness.ready,
|
||||||
|
"new approval cannot contradict snapshot request_changes"
|
||||||
|
);
|
||||||
|
mr.thread.truncate(1);
|
||||||
|
let summary = merge_request_status_summary(&mr, &mr_read_source(), report);
|
||||||
|
assert_eq!(summary.review_status, "pending");
|
||||||
|
assert!(
|
||||||
|
!summary.readiness.ready,
|
||||||
|
"new approval cannot contradict snapshot pending review"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_status_includes_page_external_approval_and_revocation() {
|
||||||
|
let complete = mr_read_fixture();
|
||||||
|
let summary = mr_read_summary(&complete);
|
||||||
|
let mut page = complete.clone();
|
||||||
|
let query = server_api::MergeRequestThreadQuery {
|
||||||
|
view: Some(server_api::MergeRequestReadView::Detail),
|
||||||
|
after: Some(2),
|
||||||
|
limit: Some(1),
|
||||||
|
};
|
||||||
|
let pagination = project_merge_request_thread(&mut page, &query);
|
||||||
|
assert_eq!(summary.review_status, "approved");
|
||||||
|
assert_eq!(
|
||||||
|
summary.review.as_ref().unwrap().event.event_id,
|
||||||
|
"approval-1"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
page.effective_review("source-1").is_none(),
|
||||||
|
"approval is outside the page"
|
||||||
|
);
|
||||||
|
assert!(!pagination.has_more);
|
||||||
|
assert_eq!(pagination.next_after, None);
|
||||||
|
|
||||||
|
let mut revoked = complete;
|
||||||
|
revoked.thread.push(serde_json::from_value(json!({
|
||||||
|
"kind": "review_revoked", "event_id": "revoke-1", "sequence": 4,
|
||||||
|
"review_event_id": "approval-1", "subject_ref": "source-1", "reason": "Found a defect",
|
||||||
|
"revoked_by": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"
|
||||||
|
})).unwrap());
|
||||||
|
let summary = mr_read_summary(&revoked);
|
||||||
|
let pagination = project_merge_request_thread(
|
||||||
|
&mut revoked,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
after: Some(1),
|
||||||
|
..query
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(summary.review_status, "none");
|
||||||
|
assert!(summary.review.is_none());
|
||||||
|
assert_eq!(summary.latest_event.unwrap().event_id, "revoke-1");
|
||||||
|
assert!(
|
||||||
|
revoked.effective_review("source-1").is_some(),
|
||||||
|
"page alone would falsely approve"
|
||||||
|
);
|
||||||
|
assert!(pagination.has_more);
|
||||||
|
assert_eq!(pagination.next_after, Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_summary_is_body_independent_and_detail_preserves_exact_findings() {
|
||||||
|
let mut complete = mr_read_fixture();
|
||||||
|
let merge_request::MergeRequestThreadEvent::Review(review) = &mut complete.thread[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
review.decision = merge_request::ReviewDecision::RequestChanges;
|
||||||
|
review.findings = vec![
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"severity": "major", "code": "regression", "path": "src/lib.rs", "line": 42,
|
||||||
|
"body": "Exact finding"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
];
|
||||||
|
let small = serde_json::to_vec(&mr_read_summary(&complete)).unwrap();
|
||||||
|
let merge_request::MergeRequestThreadEvent::Review(review) = &mut complete.thread[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
review.body = "review evidence ".repeat(10_000);
|
||||||
|
review.findings[0].body = "finding evidence ".repeat(10_000);
|
||||||
|
let summary = mr_read_summary(&complete);
|
||||||
|
let compact = serde_json::to_vec(&summary).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
small, compact,
|
||||||
|
"summary contains no review/finding excerpts or bodies"
|
||||||
|
);
|
||||||
|
assert_eq!(summary.review_status, "request_changes");
|
||||||
|
assert_eq!(summary.review.as_ref().unwrap().findings_count, 1);
|
||||||
|
let original_review = complete.thread[1].clone();
|
||||||
|
let legacy = serde_json::to_vec(&complete).unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"MR fixture bytes: full={}, summary={}",
|
||||||
|
legacy.len(),
|
||||||
|
compact.len()
|
||||||
|
);
|
||||||
|
assert!(compact.len() * 100 < legacy.len());
|
||||||
|
|
||||||
|
let mut summary_mr = complete.clone();
|
||||||
|
let omitted = project_merge_request_thread(
|
||||||
|
&mut summary_mr,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
view: Some(server_api::MergeRequestReadView::Summary),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(summary_mr.thread.is_empty());
|
||||||
|
assert!(omitted.omitted && omitted.has_more);
|
||||||
|
assert_eq!(
|
||||||
|
omitted.next_after, None,
|
||||||
|
"start detail at the beginning, not the latest event"
|
||||||
|
);
|
||||||
|
|
||||||
|
let event = &summary.review.unwrap().event;
|
||||||
|
let page = project_merge_request_thread(
|
||||||
|
&mut complete,
|
||||||
|
&server_api::MergeRequestThreadQuery {
|
||||||
|
view: Some(server_api::MergeRequestReadView::Detail),
|
||||||
|
after: Some(event.sequence - 1),
|
||||||
|
limit: Some(1),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(complete.thread, vec![original_review]);
|
||||||
|
assert_eq!(page.next_after, Some(event.sequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mr_read_summary_preserves_unknown_movement_pending_and_merged_evidence() {
|
||||||
|
let mut mr = mr_read_fixture();
|
||||||
|
let readiness = mr_read_summary(&mr).readiness;
|
||||||
|
let mut source = mr_read_source();
|
||||||
|
source.status = "unknown".into();
|
||||||
|
let summary = merge_request_status_summary(&mr, &source, readiness.clone());
|
||||||
|
assert_eq!(summary.review_status, "unknown");
|
||||||
|
assert!(
|
||||||
|
summary.review.is_none(),
|
||||||
|
"retained ref does not establish observation"
|
||||||
|
);
|
||||||
|
source.status = "known".into();
|
||||||
|
source.resolved_ref = Some("source-2".into());
|
||||||
|
let summary = merge_request_status_summary(&mr, &source, readiness.clone());
|
||||||
|
assert_eq!(summary.review_status, "none");
|
||||||
|
assert_eq!(
|
||||||
|
summary.latest_review_subject_ref.as_deref(),
|
||||||
|
Some("source-1")
|
||||||
|
);
|
||||||
|
mr.thread.truncate(1);
|
||||||
|
assert_eq!(mr_read_summary(&mr).review_status, "pending");
|
||||||
|
mr.state = merge_request::MergeRequestState::Merged;
|
||||||
|
assert!(
|
||||||
|
mr_read_summary(&mr).review.is_none(),
|
||||||
|
"merged state alone is not approval"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn merge_request_reads_use_first_class_workspace_resources() {
|
async fn merge_request_reads_use_first_class_workspace_resources() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -392,6 +392,33 @@ impl EvidenceApiFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mr_summary_route_omits_history_but_keeps_page_external_approval() {
|
||||||
|
let fixture = EvidenceApiFixture::new().await;
|
||||||
|
let approval = fixture.approve("summary-route");
|
||||||
|
fixture.integrate(&approval);
|
||||||
|
let value = get_json(
|
||||||
|
fixture.app.clone(),
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/{EVIDENCE_MR}?view=summary"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let summary: MergeRequestDetailResponse = serde_json::from_value(value).unwrap();
|
||||||
|
assert!(summary.merge_request.thread.is_empty());
|
||||||
|
assert!(summary.event_page.omitted);
|
||||||
|
assert!(summary.event_page.has_more);
|
||||||
|
assert_eq!(summary.summary.review_status, "approved");
|
||||||
|
assert_eq!(
|
||||||
|
summary.summary.review.as_ref().unwrap().event.event_id,
|
||||||
|
approval.event_id
|
||||||
|
);
|
||||||
|
assert!(summary.summary.readiness.review.is_none());
|
||||||
|
let page = fixture.detail(0, 1).await;
|
||||||
|
assert_eq!(page.merge_request.thread.len(), 1);
|
||||||
|
assert!(page.event_page.has_more);
|
||||||
|
assert!(page.event_page.next_after.is_some());
|
||||||
|
assert_eq!(page.summary, summary.summary);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
|
async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
|
||||||
let fixture = EvidenceApiFixture::new().await;
|
let fixture = EvidenceApiFixture::new().await;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Assignは担当への委譲であり、readyやQueueの操作を前提にしな
|
|||||||
|
|
||||||
実装・routing・レビュー・終了の判断前には、現行Ticketと必要な関連記録を読む。通知、要約、過去の実装報告を現行の要求や承認の証拠にしない。Worker内の操作は、提供されたtyped toolsとsystem instructionsに従う。CLIや保存先の直接編集で欠けた権限を代替しない。typed Ticket toolsのないクライアントでは、Ticketの作成・更新をYoi Workerへ委ねる。
|
実装・routing・レビュー・終了の判断前には、現行Ticketと必要な関連記録を読む。通知、要約、過去の実装報告を現行の要求や承認の証拠にしない。Worker内の操作は、提供されたtyped toolsとsystem instructionsに従う。CLIや保存先の直接編集で欠けた権限を代替しない。typed Ticket toolsのないクライアントでは、Ticketの作成・更新をYoi Workerへ委ねる。
|
||||||
|
|
||||||
|
通常の進捗確認は`ShowTicket(view="summary")`を使い、要求初読・`content_digest`の変化・内容を要する判断では`detail`へ進む。モデル向け要求本文は`initial_post.body`に一度だけ置き、MRは`merge_requests`を正規の一覧とする。`history`は本文を反復しないページ、`post`は観測済み`event_ref`による全文取得であり、切詰め本文を編集CASに使わない。全文post読取りは1 MiBまでで、それを超える投稿は切詰めずエラーにする(分割全文読取りは未対応)。`ShowMergeRequest`のTool/WIPはsummaryを既定とし、履歴・review本文・findingは明示的な`detail`とcursorで読む。MR状態要約は完全な証拠から計算し、表示中の履歴ページから承認・失効を推測しない。これらはモデルへの出力を分ける契約であり、保存正本やDB読込み量の削減を意味しない。
|
||||||
|
|
||||||
成果には、実際の変更、検証結果、未確認事項、判断に必要な証拠を残す。すべての作業にコメントやartifactを義務付けず、利用者が必要とする返却方法を選ぶ。長い調査結果、生ログ、秘密情報はTicket本文へ転載しない。
|
成果には、実際の変更、検証結果、未確認事項、判断に必要な証拠を残す。すべての作業にコメントやartifactを義務付けず、利用者が必要とする返却方法を選ぶ。長い調査結果、生ログ、秘密情報はTicket本文へ転載しない。
|
||||||
|
|
||||||
対応終了と案件終了はそれぞれ明示的に判断する。結果を返して案件を開いたままにすることもできる。要求充足の判断は担当Worker・利用者の責任であり、Backendの認可・競合検査の成功や、成果物・承認済みMRの存在が代わりに判定するものではない。
|
対応終了と案件終了はそれぞれ明示的に判断する。結果を返して案件を開いたままにすることもできる。要求充足の判断は担当Worker・利用者の責任であり、Backendの認可・競合検査の成功や、成果物・承認済みMRの存在が代わりに判定するものではない。
|
||||||
|
|||||||
+173
-2
@@ -4933,6 +4933,9 @@
|
|||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"event_page": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestEventPage"
|
||||||
|
},
|
||||||
"linked_tickets": {
|
"linked_tickets": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/MergeRequestLinkedTicketResponse"
|
"$ref": "#/components/schemas/MergeRequestLinkedTicketResponse"
|
||||||
@@ -4960,6 +4963,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"$ref": "#/components/schemas/MergeRequestState"
|
"$ref": "#/components/schemas/MergeRequestState"
|
||||||
},
|
},
|
||||||
|
"summary": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestStatusSummary"
|
||||||
|
},
|
||||||
"target": {
|
"target": {
|
||||||
"$ref": "#/components/schemas/MergeRequestRefResponse"
|
"$ref": "#/components/schemas/MergeRequestRefResponse"
|
||||||
},
|
},
|
||||||
@@ -4978,6 +4984,9 @@
|
|||||||
"updated_at": {
|
"updated_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"view": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadView"
|
||||||
|
},
|
||||||
"workspace_id": {
|
"workspace_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -4994,7 +5003,63 @@
|
|||||||
"thread",
|
"thread",
|
||||||
"source",
|
"source",
|
||||||
"target",
|
"target",
|
||||||
"linked_tickets"
|
"linked_tickets",
|
||||||
|
"view",
|
||||||
|
"summary",
|
||||||
|
"event_page"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"MergeRequestEventPage": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"has_more": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 200,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"next_after": {
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"omitted": {
|
||||||
|
"description": "Summary deliberately omits events. Read detail from the beginning to obtain history.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"omitted",
|
||||||
|
"has_more",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"MergeRequestEventReference": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"event_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"description": "Exact event: read detail with after = sequence - 1 and limit = 1.",
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"event_id",
|
||||||
|
"sequence"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -5120,6 +5185,13 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MergeRequestReadView": {
|
||||||
|
"enum": [
|
||||||
|
"summary",
|
||||||
|
"detail"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"MergeRequestReadinessResponse": {
|
"MergeRequestReadinessResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5219,6 +5291,42 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MergeRequestReviewSummary": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"decision": {
|
||||||
|
"$ref": "#/components/schemas/ReviewDecision"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestEventReference"
|
||||||
|
},
|
||||||
|
"findings_count": {
|
||||||
|
"description": "Findings retain their order in the exact review event; no finding body is truncated here.",
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"request_event_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subject_ref": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ticket_content_digest": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"event",
|
||||||
|
"request_event_id",
|
||||||
|
"subject_ref",
|
||||||
|
"ticket_content_digest",
|
||||||
|
"decision",
|
||||||
|
"findings_count"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MergeRequestState": {
|
"MergeRequestState": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"open",
|
"open",
|
||||||
@@ -5227,6 +5335,58 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"MergeRequestStatusSummary": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Computed from complete persisted evidence, never from the returned history page.\nThis observation is not integration authority or current-requirements approval.",
|
||||||
|
"properties": {
|
||||||
|
"latest_event": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MergeRequestEventReference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"latest_review_subject_ref": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"readiness": {
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadinessResponse",
|
||||||
|
"description": "Existing readiness semantics; review bodies are omitted in this compact projection."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReviewSummary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"review_status": {
|
||||||
|
"description": "unknown, none, pending, approved, or request_changes for the observed source.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"thread_event_count": {
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"review_status",
|
||||||
|
"thread_event_count",
|
||||||
|
"readiness"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MergeRequestThreadEvent": {
|
"MergeRequestThreadEvent": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -5548,6 +5708,17 @@
|
|||||||
"integer",
|
"integer",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MergeRequestReadView"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Show only: summary omits history; detail returns an exact bounded thread page.\nOmission retains the existing HTTP detail response."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -18307,7 +18478,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962"
|
"x-yoi-source-digest": "sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
MR bookkeeping is separate from selecting and adopting changes. `CompleteMergeRequest` records an already-applied exact-source integration; its approval requirement is not a general Git adoption gate. Partial or adapted adoption is not full MR integration.
|
MR bookkeeping is separate from selecting and adopting changes. `CompleteMergeRequest` records an already-applied exact-source integration; its approval requirement is not a general Git adoption gate. Partial or adapted adoption is not full MR integration.
|
||||||
|
|
||||||
|
`ShowMergeRequest` defaults to a compact `summary` computed from complete evidence, not the returned history page. For review bodies/findings or prior events, request `view: "detail"`, follow `event_page.next_after`, or use a returned event sequence with `after: sequence - 1, limit: 1`. Summary readiness is an observation, not permission to integrate; unknown source observations remain unknown.
|
||||||
|
|
||||||
When using MRs, address each by `merge_request_id`. Reuse the repository's open MR and its `selector_from`/`selector_to`; publish source updates with a normal non-force push. Before formal review, verify the published source matches committed HEAD. Keep review/fix evidence on the MR, not repeated Ticket comments.
|
When using MRs, address each by `merge_request_id`. Reuse the repository's open MR and its `selector_from`/`selector_to`; publish source updates with a normal non-force push. Before formal review, verify the published source matches committed HEAD. Keep review/fix evidence on the MR, not repeated Ticket comments.
|
||||||
|
|
||||||
Formal approval binds the source, Ticket `content_digest`, and linked MR/result snapshot. Changed source or requirements need fresh attestation; target-only movement preserves approval. `CheckMergeRequestReadiness` checks this recording workflow. Ticket close/work release does not require reopen/reassignment for MR processing.
|
Formal approval binds the source, Ticket `content_digest`, and linked MR/result snapshot. Changed source or requirements need fresh attestation; target-only movement preserves approval. `CheckMergeRequestReadiness` checks this recording workflow. Ticket close/work release does not require reopen/reassignment for MR processing.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item content and `content_digest`, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item content and `content_digest`, thread/evidence, relations, linked Objectives, and any current Merge Request context before routing, review, or conclusion decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
||||||
|
|
||||||
|
For routine progress or notification checks, use `ShowTicket` with `view: "summary"`; it omits prose and history, not authority checks. Read `view: "detail"` for the initial requirements, changed `content_digest`, or decisions needing content. Use `view: "history"` with the returned cursor for older events, and `view: "post"` with an observed `event_ref` for exact text before editing a truncated preview. The canonical requirement body is `initial_post.body`; linked MRs are in `merge_requests`. A summary or cached observation never substitutes for required rereads, edit CAS, or approval.
|
||||||
|
|
||||||
Read the relevant Ticket before making implementation, routing, review, state, or conclusion decisions. Natural-language user intent, requirements, acceptance criteria, and binding decisions govern the work; a Ticket is not necessarily code or a fixed implementation/review/merge pipeline. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
|
Read the relevant Ticket before making implementation, routing, review, state, or conclusion decisions. Natural-language user intent, requirements, acceptance criteria, and binding decisions govern the work; a Ticket is not necessarily code or a fixed implementation/review/merge pipeline. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
|
||||||
|
|
||||||
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative content and linked-Ticket context when coordinating broader work.
|
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and useful evidence. Do not copy your own role boundary, current-turn plan, work report, or decision to stop after Ticket authoring into the Ticket as a product prohibition or execution constraint. Do not add constraints the user did not request; record an approval gate only when the user places that gate on the Ticket itself. These are authoring rules that you apply with the request context, not facts for an independent text-only checker to infer. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative content and linked-Ticket context when coordinating broader work.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// 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.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// 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
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// 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.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// 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.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// 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
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
@@ -37,7 +37,11 @@ export type InvalidProjectRecord = { label: string; reason: string; };
|
|||||||
|
|
||||||
export type MarkdownText = string;
|
export type MarkdownText = string;
|
||||||
|
|
||||||
export type MergeRequestDetailResponse = { created_at: string; linked_tickets: Array<MergeRequestLinkedTicketResponse>; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; target: MergeRequestRefResponse; thread: Array<MergeRequestThreadEvent>; ticket_ids: Array<string>; updated_at: string; workspace_id: string; };
|
export type MergeRequestDetailResponse = { created_at: string; event_page: MergeRequestEventPage; linked_tickets: Array<MergeRequestLinkedTicketResponse>; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; summary: MergeRequestStatusSummary; target: MergeRequestRefResponse; thread: Array<MergeRequestThreadEvent>; ticket_ids: Array<string>; updated_at: string; view: MergeRequestReadView; workspace_id: string; };
|
||||||
|
|
||||||
|
export type MergeRequestEventPage = { has_more: boolean; limit: number & { readonly "__yoiIntegerRange": "1..200"; }; next_after?: number | null; omitted: boolean; };
|
||||||
|
|
||||||
|
export type MergeRequestEventReference = { event_id: string; sequence: number; };
|
||||||
|
|
||||||
export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; };
|
export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; };
|
||||||
|
|
||||||
@@ -45,14 +49,22 @@ export type MergeRequestListItem = { ref_diagnostics?: Array<MergeRequestRefDiag
|
|||||||
|
|
||||||
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>; next_cursor?: string | null; };
|
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>; next_cursor?: string | null; };
|
||||||
|
|
||||||
|
export type MergeRequestReadView = "summary" | "detail";
|
||||||
|
|
||||||
|
export type MergeRequestReadinessResponse = { blockers: Array<string>; ready: boolean; review?: ReviewEvent | null; subject_ref?: string | null; };
|
||||||
|
|
||||||
export type MergeRequestRefDiagnostic = { code: string; message: string; };
|
export type MergeRequestRefDiagnostic = { code: string; message: string; };
|
||||||
|
|
||||||
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
|
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
|
||||||
|
|
||||||
export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: string; };
|
export type MergeRequestReviewSubject = { merge_request_id: string; subject_ref: string; };
|
||||||
|
|
||||||
|
export type MergeRequestReviewSummary = { decision: ReviewDecision; event: MergeRequestEventReference; findings_count: number; request_event_id: string; subject_ref: string; ticket_content_digest: string; };
|
||||||
|
|
||||||
export type MergeRequestState = "open" | "merged" | "closed";
|
export type MergeRequestState = "open" | "merged" | "closed";
|
||||||
|
|
||||||
|
export type MergeRequestStatusSummary = { latest_event?: MergeRequestEventReference | null; latest_review_subject_ref?: string | null; readiness: MergeRequestReadinessResponse; review?: MergeRequestReviewSummary | null; review_status: string; thread_event_count: number; };
|
||||||
|
|
||||||
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
|
||||||
|
|
||||||
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
|
||||||
@@ -91,6 +103,8 @@ export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: strin
|
|||||||
|
|
||||||
export type ReviewDecision = "approve" | "request_changes";
|
export type ReviewDecision = "approve" | "request_changes";
|
||||||
|
|
||||||
|
export type ReviewEvent = { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; ticket_content_digest: string; ticket_merge_request_subjects: Array<MergeRequestReviewSubject>; };
|
||||||
|
|
||||||
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
|
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
|
||||||
|
|
||||||
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3436eada52ceea48f4c50f40195cba02bec37fbbff4ea8f5a332133e6fcde962)
|
// Input contract: 0.1.0 (sha256:d5817a2c8527f1859b68e9824b3b277987980f228b0692a2569708a57035fd10)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// 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
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -608,6 +608,16 @@ Deno.test("Ticket Browser parser accepts the generated Objective list shape", ()
|
|||||||
|
|
||||||
Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => {
|
Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => {
|
||||||
const parsed = parseMergeRequestDetailResponse({
|
const parsed = parseMergeRequestDetailResponse({
|
||||||
|
view: "detail",
|
||||||
|
summary: {
|
||||||
|
review_status: "pending",
|
||||||
|
review: null,
|
||||||
|
latest_review_subject_ref: "abc123",
|
||||||
|
latest_event: { event_id: "event-1", sequence: 1 },
|
||||||
|
thread_event_count: 1,
|
||||||
|
readiness: { ready: false, blockers: ["review_missing"], subject_ref: "abc123" },
|
||||||
|
},
|
||||||
|
event_page: { omitted: false, has_more: false, next_after: null, limit: 100 },
|
||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
merge_request_id: "mr-1",
|
merge_request_id: "mr-1",
|
||||||
repository_key: "main",
|
repository_key: "main",
|
||||||
@@ -643,6 +653,16 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
|
|||||||
linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }],
|
linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }],
|
||||||
});
|
});
|
||||||
assertEquals(parsed.thread[0]?.kind, "review_requested");
|
assertEquals(parsed.thread[0]?.kind, "review_requested");
|
||||||
|
assertEquals(parsed.summary.review_status, "pending");
|
||||||
|
assertEquals(parsed.summary.latest_event?.event_id, "event-1");
|
||||||
|
assertThrows(
|
||||||
|
() => parseMergeRequestDetailResponse({ ...parsed, summary: { ...parsed.summary, review_status: "invented" } }),
|
||||||
|
"review_status is invalid",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseMergeRequestDetailResponse({ ...parsed, event_page: { ...parsed.event_page, next_after: -1 } }),
|
||||||
|
"next_after",
|
||||||
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseMergeRequestDetailResponse({
|
parseMergeRequestDetailResponse({
|
||||||
|
|||||||
@@ -1727,6 +1727,82 @@ function parseLinkedTicket(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMergeRequestEventReference(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): NonNullable<MergeRequestDetailResponse["summary"]["latest_event"]> | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
const item = object(value, label);
|
||||||
|
exact(item, ["event_id", "sequence"], label);
|
||||||
|
return {
|
||||||
|
event_id: string(item.event_id, `${label}.event_id`),
|
||||||
|
sequence: integer(item.sequence, `${label}.sequence`, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMergeRequestStatusSummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): MergeRequestDetailResponse["summary"] {
|
||||||
|
const item = object(value, label);
|
||||||
|
exact(item, ["review_status", "review", "latest_review_subject_ref", "latest_event", "thread_event_count", "readiness"], label);
|
||||||
|
const status = string(item.review_status, `${label}.review_status`);
|
||||||
|
if (!["unknown", "none", "pending", "approved", "request_changes"].includes(status)) {
|
||||||
|
throw new TicketBrowserContractError(`${label}.review_status is invalid`);
|
||||||
|
}
|
||||||
|
let review: MergeRequestDetailResponse["summary"]["review"] = null;
|
||||||
|
if (item.review !== null) {
|
||||||
|
const r = object(item.review, `${label}.review`);
|
||||||
|
exact(r, ["event", "request_event_id", "subject_ref", "ticket_content_digest", "decision", "findings_count"], `${label}.review`);
|
||||||
|
const event = parseMergeRequestEventReference(r.event, `${label}.review.event`);
|
||||||
|
if (event === null) throw new TicketBrowserContractError(`${label}.review.event is required`);
|
||||||
|
const decision = string(r.decision, `${label}.review.decision`);
|
||||||
|
if (decision !== "approve" && decision !== "request_changes") {
|
||||||
|
throw new TicketBrowserContractError(`${label}.review.decision is invalid`);
|
||||||
|
}
|
||||||
|
review = {
|
||||||
|
event,
|
||||||
|
request_event_id: string(r.request_event_id, `${label}.review.request_event_id`),
|
||||||
|
subject_ref: string(r.subject_ref, `${label}.review.subject_ref`),
|
||||||
|
ticket_content_digest: string(r.ticket_content_digest, `${label}.review.ticket_content_digest`),
|
||||||
|
decision,
|
||||||
|
findings_count: integer(r.findings_count, `${label}.review.findings_count`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const readiness = object(item.readiness, `${label}.readiness`);
|
||||||
|
exact(readiness, ["ready", "blockers", "subject_ref", "review"], `${label}.readiness`);
|
||||||
|
// The compact summary never embeds a full review; its stable reference is above.
|
||||||
|
if (readiness.review !== undefined && readiness.review !== null) {
|
||||||
|
throw new TicketBrowserContractError(`${label}.readiness.review must be omitted`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
review_status: status,
|
||||||
|
review,
|
||||||
|
latest_review_subject_ref: nullableString(item.latest_review_subject_ref, `${label}.latest_review_subject_ref`),
|
||||||
|
latest_event: parseMergeRequestEventReference(item.latest_event, `${label}.latest_event`),
|
||||||
|
thread_event_count: integer(item.thread_event_count, `${label}.thread_event_count`),
|
||||||
|
readiness: {
|
||||||
|
ready: boolean(readiness.ready, `${label}.readiness.ready`),
|
||||||
|
blockers: strings(readiness.blockers, `${label}.readiness.blockers`),
|
||||||
|
subject_ref: optionalNullableString(readiness.subject_ref, `${label}.readiness.subject_ref`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMergeRequestEventPage(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): MergeRequestDetailResponse["event_page"] {
|
||||||
|
const item = object(value, label);
|
||||||
|
exact(item, ["omitted", "has_more", "next_after", "limit"], label);
|
||||||
|
return {
|
||||||
|
omitted: boolean(item.omitted, `${label}.omitted`),
|
||||||
|
has_more: boolean(item.has_more, `${label}.has_more`),
|
||||||
|
next_after: item.next_after === null ? null : integer(item.next_after, `${label}.next_after`, 1),
|
||||||
|
limit: integer(item.limit, `${label}.limit`, 1, 200) as MergeRequestDetailResponse["event_page"]["limit"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMergeRequestDetailResponse(
|
export function parseMergeRequestDetailResponse(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): MergeRequestDetailResponse {
|
): MergeRequestDetailResponse {
|
||||||
@@ -1743,6 +1819,9 @@ export function parseMergeRequestDetailResponse(
|
|||||||
"state",
|
"state",
|
||||||
"target",
|
"target",
|
||||||
"thread",
|
"thread",
|
||||||
|
"summary",
|
||||||
|
"view",
|
||||||
|
"event_page",
|
||||||
"ticket_ids",
|
"ticket_ids",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
@@ -1751,7 +1830,14 @@ export function parseMergeRequestDetailResponse(
|
|||||||
if (!["open", "merged", "closed"].includes(state)) {
|
if (!["open", "merged", "closed"].includes(state)) {
|
||||||
throw new TicketBrowserContractError(`${label}.state is invalid`);
|
throw new TicketBrowserContractError(`${label}.state is invalid`);
|
||||||
}
|
}
|
||||||
|
const view = string(item.view, `${label}.view`);
|
||||||
|
if (view !== "summary" && view !== "detail") {
|
||||||
|
throw new TicketBrowserContractError(`${label}.view is invalid`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
view,
|
||||||
|
summary: parseMergeRequestStatusSummary(item.summary, `${label}.summary`),
|
||||||
|
event_page: parseMergeRequestEventPage(item.event_page, `${label}.event_page`),
|
||||||
created_at: string(item.created_at, `${label}.created_at`),
|
created_at: string(item.created_at, `${label}.created_at`),
|
||||||
linked_tickets: array(
|
linked_tickets: array(
|
||||||
item.linked_tickets,
|
item.linked_tickets,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ Deno.test("closed Ticket can retain immutable approval while current requirement
|
|||||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
|
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
|
||||||
const source = await Deno.readTextFile(
|
const source = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
"../../routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -164,7 +164,7 @@ Deno.test("Ticket route separates current requirement evidence from immutable in
|
|||||||
Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => {
|
Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => {
|
||||||
const list = await Deno.readTextFile(
|
const list = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../routes/w/[workspaceId]/merge-requests/+page.svelte",
|
"../../routes/w/[workspaceId]/merge-requests/PageContent.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -172,7 +172,7 @@ Deno.test("Merge Request routes distinguish recorded approval from live source o
|
|||||||
assertEquals(list.includes("Integration evidence error:"), true);
|
assertEquals(list.includes("Integration evidence error:"), true);
|
||||||
const detail = await Deno.readTextFile(
|
const detail = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte",
|
"../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/PageContent.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -183,6 +183,23 @@ Deno.test("Merge Request routes distinguish recorded approval from live source o
|
|||||||
|
|
||||||
Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => {
|
Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => {
|
||||||
const detail: MergeRequestDetail = {
|
const detail: MergeRequestDetail = {
|
||||||
|
view: "detail",
|
||||||
|
summary: {
|
||||||
|
review_status: "approved",
|
||||||
|
review: {
|
||||||
|
event: { event_id: "approval-1", sequence: 2 },
|
||||||
|
request_event_id: "request-1",
|
||||||
|
subject_ref: "source-1",
|
||||||
|
ticket_content_digest: "a".repeat(64),
|
||||||
|
decision: "approve",
|
||||||
|
findings_count: 0,
|
||||||
|
},
|
||||||
|
latest_review_subject_ref: "source-1",
|
||||||
|
latest_event: { event_id: "merge-1", sequence: 3 },
|
||||||
|
thread_event_count: 3,
|
||||||
|
readiness: { ready: false, blockers: ["already_merged"] },
|
||||||
|
},
|
||||||
|
event_page: { omitted: false, has_more: false, next_after: null, limit: 100 as MergeRequestDetail["event_page"]["limit"] },
|
||||||
merge_request_id: "mr-1",
|
merge_request_id: "mr-1",
|
||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
repository_key: "main",
|
repository_key: "main",
|
||||||
@@ -224,7 +241,7 @@ Deno.test("merged detail uses backend validated immutable evidence even with a p
|
|||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness({ ...detail, thread: [] }),
|
sourceReviewFreshness({ ...detail, thread: [] }),
|
||||||
"Recorded integration approval for immutable merged source source-1.",
|
"Recorded integration approval approval-1 for immutable merged source source-1.",
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness({
|
sourceReviewFreshness({
|
||||||
|
|||||||
@@ -1,99 +1,52 @@
|
|||||||
/** reviewの有効性はsubject_refと現在source refの一致で判断する。
|
/** Review status comes from the Backend's complete-evidence summary, never the
|
||||||
* 取消し済みreviewを除き、Serverが渡したthread順で有効なeventを選ぶ。
|
* returned history page. Unknown source observations remain unknown; target-only
|
||||||
* 新しい応答やtarget移動だけでは別sourceへの承認にならない。merged表示は
|
* movement does not invalidate unchanged-source approval.
|
||||||
* Backendが検証したimmutable integration evidenceを使い、新しいlive観測で上書きしない。
|
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
TicketEvidenceSummary,
|
TicketEvidenceSummary,
|
||||||
TicketMergeRequestSummary,
|
TicketMergeRequestSummary,
|
||||||
} from "#lib/generated/ticket-api.ts";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import type {
|
import type { MergeRequestDetail } from "./api/merge-requests.ts";
|
||||||
MergeRequestDetail,
|
|
||||||
MergeRequestThreadEvent,
|
|
||||||
} from "./api/merge-requests.ts";
|
|
||||||
|
|
||||||
type ReviewEvent = Extract<MergeRequestThreadEvent, { kind: "review" }>;
|
|
||||||
type SourceEvidenceEvent = Extract<
|
|
||||||
MergeRequestThreadEvent,
|
|
||||||
{ kind: "review" | "review_requested" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
function isCurrentSourceEvent(
|
|
||||||
event: MergeRequestThreadEvent,
|
|
||||||
source: string,
|
|
||||||
): boolean {
|
|
||||||
return "subject_ref" in event && event.subject_ref === source;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestHasTerminalOutcome(
|
|
||||||
thread: MergeRequestThreadEvent[],
|
|
||||||
requestEventId: unknown,
|
|
||||||
): boolean {
|
|
||||||
return thread.some((event) =>
|
|
||||||
(event.kind === "review" || event.kind === "review_cancelled") &&
|
|
||||||
event.request_event_id === requestEventId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sourceReviewFreshness(
|
export function sourceReviewFreshness(
|
||||||
mergeRequest: MergeRequestDetail,
|
mergeRequest: MergeRequestDetail,
|
||||||
): string {
|
): string {
|
||||||
|
const source = mergeRequest.source.ref;
|
||||||
if (mergeRequest.state === "merged") {
|
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) {
|
if (mergeRequest.source.diagnostic) {
|
||||||
return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`;
|
return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`;
|
||||||
}
|
}
|
||||||
const source = mergeRequest.source.ref;
|
|
||||||
if (mergeRequest.source.status !== "known" || !source) {
|
if (mergeRequest.source.status !== "known" || !source) {
|
||||||
return "Immutable integration evidence unavailable: no validated persisted source.";
|
return "Immutable integration evidence unavailable: no validated persisted source.";
|
||||||
}
|
}
|
||||||
const merge = [...mergeRequest.thread].reverse().find(
|
const approval = mergeRequest.summary?.review;
|
||||||
(event): event is Extract<MergeRequestThreadEvent, { kind: "merge" }> =>
|
return approval && mergeRequest.summary.review_status === "approved"
|
||||||
event.kind === "merge",
|
? `Recorded integration approval ${approval.event.event_id} for immutable merged source ${source}.`
|
||||||
);
|
: `Integration approval unavailable for immutable merged source ${source}.`;
|
||||||
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 (mergeRequest.source.status !== "known" || !source) {
|
||||||
if (!source) return "Source review unavailable: selector_from is unresolved.";
|
return mergeRequest.source.diagnostic
|
||||||
|
? `Source review unavailable: ${mergeRequest.source.diagnostic.code}.`
|
||||||
const effectiveReview = [...mergeRequest.thread].reverse().find(
|
: "Source review unavailable: selector_from is unresolved.";
|
||||||
(event): event is ReviewEvent => {
|
|
||||||
if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !mergeRequest.thread.some(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.kind === "review_revoked" &&
|
|
||||||
candidate.review_event_id === event.event_id,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (effectiveReview) {
|
|
||||||
return effectiveReview.decision === "approve"
|
|
||||||
? `Current source approved at exact ref ${source}.`
|
|
||||||
: `Current source requests changes at exact ref ${source}.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestEvidence = [...mergeRequest.thread].reverse().find(
|
const summary = mergeRequest.summary;
|
||||||
(event): event is SourceEvidenceEvent =>
|
// Do not fall back to partial-thread inference for an older/malformed response.
|
||||||
event.kind === "review" || event.kind === "review_requested",
|
if (!summary || summary.review_status === "unknown") {
|
||||||
);
|
return "Source review unavailable: authoritative summary is unavailable.";
|
||||||
if (latestEvidence?.subject_ref && latestEvidence.subject_ref !== source) {
|
|
||||||
return `Fresh source review required: selector_from moved from ${latestEvidence.subject_ref} to ${source}.`;
|
|
||||||
}
|
}
|
||||||
|
switch (summary.review_status) {
|
||||||
const pendingRequest = [...mergeRequest.thread].reverse().find((event) =>
|
case "approved":
|
||||||
event.kind === "review_requested" &&
|
return `Current source approved at exact ref ${source}.`;
|
||||||
isCurrentSourceEvent(event, source) &&
|
case "request_changes":
|
||||||
!requestHasTerminalOutcome(mergeRequest.thread, event.event_id)
|
return `Current source requests changes at exact ref ${source}.`;
|
||||||
);
|
case "pending":
|
||||||
if (pendingRequest) {
|
return `Current source review pending for exact ref ${source}.`;
|
||||||
return `Current source review pending for exact ref ${source}.`;
|
}
|
||||||
|
const previous = summary.latest_review_subject_ref;
|
||||||
|
if (previous && previous !== source) {
|
||||||
|
return `Fresh source review required: selector_from moved from ${previous} to ${source}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,8 +106,22 @@ function reviewCancelled(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
|
function detail(
|
||||||
|
thread: MergeRequestThreadEvent[],
|
||||||
|
reviewStatus = "none",
|
||||||
|
latestSubject = "source-2",
|
||||||
|
): MergeRequestDetail {
|
||||||
return {
|
return {
|
||||||
|
view: "detail",
|
||||||
|
summary: {
|
||||||
|
review_status: reviewStatus,
|
||||||
|
review: null,
|
||||||
|
latest_review_subject_ref: latestSubject,
|
||||||
|
latest_event: null,
|
||||||
|
thread_event_count: thread.length,
|
||||||
|
readiness: { ready: false, blockers: [] },
|
||||||
|
},
|
||||||
|
event_page: { omitted: false, has_more: false, next_after: null, limit: 100 as MergeRequestDetail["event_page"]["limit"] },
|
||||||
merge_request_id: "MR-1",
|
merge_request_id: "MR-1",
|
||||||
workspace_id: "W",
|
workspace_id: "W",
|
||||||
repository_key: "main",
|
repository_key: "main",
|
||||||
@@ -148,7 +162,7 @@ Deno.test("revoked review requires a fresh review instead of appearing pending",
|
|||||||
Deno.test("unresolved review request for the current source is pending", () => {
|
Deno.test("unresolved review request for the current source is pending", () => {
|
||||||
const mergeRequest = detail([
|
const mergeRequest = detail([
|
||||||
reviewRequested("request-2", "source-2"),
|
reviewRequested("request-2", "source-2"),
|
||||||
]);
|
], "pending");
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness(mergeRequest),
|
sourceReviewFreshness(mergeRequest),
|
||||||
@@ -160,7 +174,7 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
|
|||||||
const approved = detail([
|
const approved = detail([
|
||||||
reviewRequested("request-3", "source-2"),
|
reviewRequested("request-3", "source-2"),
|
||||||
review("review-3", "request-3", "source-2"),
|
review("review-3", "request-3", "source-2"),
|
||||||
]);
|
], "approved");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness(approved),
|
sourceReviewFreshness(approved),
|
||||||
"Current source approved at exact ref source-2.",
|
"Current source approved at exact ref source-2.",
|
||||||
@@ -176,10 +190,22 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("status uses complete backend evidence rather than a selected history page", () => {
|
||||||
|
// The visible approval has been revoked outside this page.
|
||||||
|
const revoked = detail([review("old-approval", "old-request", "source-2")], "none");
|
||||||
|
assertEquals(sourceReviewFreshness(revoked),
|
||||||
|
"Fresh source review required: no effective verdict exists for source-2.");
|
||||||
|
// The current verdict need not appear in the returned page at all.
|
||||||
|
assertEquals(sourceReviewFreshness(detail([], "approved")),
|
||||||
|
"Current source approved at exact ref source-2.");
|
||||||
|
assertEquals(sourceReviewFreshness(detail([], "request_changes")),
|
||||||
|
"Current source requests changes at exact ref source-2.");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("source movement explains the exact stale and current refs", () => {
|
Deno.test("source movement explains the exact stale and current refs", () => {
|
||||||
const mergeRequest = detail([
|
const mergeRequest = detail([
|
||||||
review("review-old", "request-old", "source-1"),
|
review("review-old", "request-old", "source-1"),
|
||||||
]);
|
], "none", "source-1");
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sourceReviewFreshness(mergeRequest),
|
sourceReviewFreshness(mergeRequest),
|
||||||
|
|||||||
Reference in New Issue
Block a user