merge: tolerate invalid panel tickets
This commit is contained in:
commit
863b13b687
|
|
@ -765,6 +765,24 @@ pub struct TicketSummary {
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TicketInvalidRecord {
|
||||||
|
pub label: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct TicketPartialList {
|
||||||
|
pub tickets: Vec<TicketSummary>,
|
||||||
|
pub invalid_records: Vec<TicketInvalidRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TicketPartial {
|
||||||
|
pub ticket: Ticket,
|
||||||
|
pub invalid_records: Vec<TicketInvalidRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketDocument {
|
pub struct TicketDocument {
|
||||||
pub body: MarkdownText,
|
pub body: MarkdownText,
|
||||||
|
|
@ -932,6 +950,49 @@ impl LocalTicketBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> {
|
||||||
|
let mut output = TicketPartialList::default();
|
||||||
|
let mut invalid_seen = BTreeSet::new();
|
||||||
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
|
let item = dir.join("item.md");
|
||||||
|
if !item.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match read_item_file(&item)
|
||||||
|
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||||
|
{
|
||||||
|
Ok(meta) => {
|
||||||
|
if filter
|
||||||
|
.state
|
||||||
|
.is_some_and(|state| meta.workflow_state != state)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.tickets.push(ticket_summary_from_meta(meta));
|
||||||
|
}
|
||||||
|
Err(error) => push_invalid_ticket_record(
|
||||||
|
&mut output.invalid_records,
|
||||||
|
&mut invalid_seen,
|
||||||
|
&dir,
|
||||||
|
&error,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_partial(&self, id: TicketIdOrSlug) -> Result<TicketPartial> {
|
||||||
|
let dir = self.find_ticket_dir(&id)?;
|
||||||
|
let mut invalid_records = Vec::new();
|
||||||
|
let mut invalid_seen = BTreeSet::new();
|
||||||
|
let ticket =
|
||||||
|
self.ticket_from_dir_tolerant(&dir, &mut invalid_records, &mut invalid_seen)?;
|
||||||
|
Ok(TicketPartial {
|
||||||
|
ticket,
|
||||||
|
invalid_records,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str {
|
fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str {
|
||||||
if is_japanese_record_language(self.record_language()) {
|
if is_japanese_record_language(self.record_language()) {
|
||||||
japanese
|
japanese
|
||||||
|
|
@ -1045,6 +1106,27 @@ impl LocalTicketBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_from_dir(&self, dir: &Path) -> Result<Ticket> {
|
fn ticket_from_dir(&self, dir: &Path) -> Result<Ticket> {
|
||||||
|
self.ticket_from_dir_with_relations(dir, |backend, meta| {
|
||||||
|
backend.relation_view_for_meta(meta)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_from_dir_tolerant(
|
||||||
|
&self,
|
||||||
|
dir: &Path,
|
||||||
|
invalid_records: &mut Vec<TicketInvalidRecord>,
|
||||||
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
|
) -> Result<Ticket> {
|
||||||
|
self.ticket_from_dir_with_relations(dir, |backend, meta| {
|
||||||
|
backend.relation_view_for_meta_tolerant(meta, invalid_records, invalid_seen)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_from_dir_with_relations(
|
||||||
|
&self,
|
||||||
|
dir: &Path,
|
||||||
|
relation_view: impl FnOnce(&Self, &TicketMeta) -> Result<TicketRelationView>,
|
||||||
|
) -> Result<Ticket> {
|
||||||
let item_path = dir.join("item.md");
|
let item_path = dir.join("item.md");
|
||||||
let parsed = read_item_file(&item_path)?;
|
let parsed = read_item_file(&item_path)?;
|
||||||
let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?;
|
let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?;
|
||||||
|
|
@ -1059,7 +1141,7 @@ impl LocalTicketBackend {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let artifacts = collect_artifacts(&dir.join("artifacts"))?;
|
let artifacts = collect_artifacts(&dir.join("artifacts"))?;
|
||||||
let relations = self.relation_view_for_meta(&meta)?;
|
let relations = relation_view(self, &meta)?;
|
||||||
let resolution_path = dir.join("resolution.md");
|
let resolution_path = dir.join("resolution.md");
|
||||||
let resolution = if resolution_path.exists() {
|
let resolution = if resolution_path.exists() {
|
||||||
Some(MarkdownText::new(
|
Some(MarkdownText::new(
|
||||||
|
|
@ -1223,13 +1305,25 @@ impl LocalTicketBackend {
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
||||||
}
|
}
|
||||||
relations.sort_by(|a, b| {
|
sort_ticket_relations(&mut relations);
|
||||||
a.ticket_id
|
Ok(relations)
|
||||||
.cmp(&b.ticket_id)
|
}
|
||||||
.then_with(|| a.kind.cmp(&b.kind))
|
|
||||||
.then_with(|| a.target.cmp(&b.target))
|
fn all_ticket_relation_records_tolerant(
|
||||||
.then_with(|| a.at.cmp(&b.at))
|
&self,
|
||||||
});
|
invalid_records: &mut Vec<TicketInvalidRecord>,
|
||||||
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
|
) -> Result<Vec<TicketRelation>> {
|
||||||
|
let mut relations = Vec::new();
|
||||||
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
|
match self.read_ticket_relations_for_dir(&dir) {
|
||||||
|
Ok(records) => relations.extend(records),
|
||||||
|
Err(error) => {
|
||||||
|
push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort_ticket_relations(&mut relations);
|
||||||
Ok(relations)
|
Ok(relations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1239,6 +1333,17 @@ impl LocalTicketBackend {
|
||||||
Ok(relation_view_from_records(meta, &all, &states))
|
Ok(relation_view_from_records(meta, &all, &states))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn relation_view_for_meta_tolerant(
|
||||||
|
&self,
|
||||||
|
meta: &TicketMeta,
|
||||||
|
invalid_records: &mut Vec<TicketInvalidRecord>,
|
||||||
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
|
) -> Result<TicketRelationView> {
|
||||||
|
let states = self.ticket_state_index_tolerant(invalid_records, invalid_seen)?;
|
||||||
|
let all = self.all_ticket_relation_records_tolerant(invalid_records, invalid_seen)?;
|
||||||
|
Ok(relation_view_from_records(meta, &all, &states))
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
|
|
@ -1249,6 +1354,28 @@ impl LocalTicketBackend {
|
||||||
Ok(states)
|
Ok(states)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_state_index_tolerant(
|
||||||
|
&self,
|
||||||
|
invalid_records: &mut Vec<TicketInvalidRecord>,
|
||||||
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
|
) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||||
|
let mut states = HashMap::new();
|
||||||
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
|
let item = dir.join("item.md");
|
||||||
|
match read_item_file(&item)
|
||||||
|
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||||
|
{
|
||||||
|
Ok(meta) => {
|
||||||
|
states.insert(meta.id, meta.workflow_state);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(states)
|
||||||
|
}
|
||||||
|
|
||||||
fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result<Vec<TicketRelationBlocker>> {
|
fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result<Vec<TicketRelationBlocker>> {
|
||||||
Ok(self.relation_view_for_meta(meta)?.blockers)
|
Ok(self.relation_view_for_meta(meta)?.blockers)
|
||||||
}
|
}
|
||||||
|
|
@ -1274,21 +1401,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
}
|
}
|
||||||
let parsed = read_item_file(&item)?;
|
let parsed = read_item_file(&item)?;
|
||||||
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?;
|
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?;
|
||||||
tickets.push(TicketSummary {
|
tickets.push(ticket_summary_from_meta(meta));
|
||||||
id: meta.id,
|
|
||||||
slug: meta.slug,
|
|
||||||
title: meta.title,
|
|
||||||
status: meta.status,
|
|
||||||
kind: meta.kind,
|
|
||||||
priority: meta.priority,
|
|
||||||
labels: meta.labels,
|
|
||||||
readiness: meta.readiness,
|
|
||||||
workflow_state: meta.workflow_state,
|
|
||||||
workflow_state_explicit: meta.workflow_state_explicit,
|
|
||||||
queued_by: meta.queued_by,
|
|
||||||
queued_at: meta.queued_at,
|
|
||||||
updated_at: meta.updated_at,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(tickets)
|
Ok(tickets)
|
||||||
}
|
}
|
||||||
|
|
@ -2224,6 +2337,72 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
|
||||||
|
TicketSummary {
|
||||||
|
id: meta.id,
|
||||||
|
slug: meta.slug,
|
||||||
|
title: meta.title,
|
||||||
|
status: meta.status,
|
||||||
|
kind: meta.kind,
|
||||||
|
priority: meta.priority,
|
||||||
|
labels: meta.labels,
|
||||||
|
readiness: meta.readiness,
|
||||||
|
workflow_state: meta.workflow_state,
|
||||||
|
workflow_state_explicit: meta.workflow_state_explicit,
|
||||||
|
queued_by: meta.queued_by,
|
||||||
|
queued_at: meta.queued_at,
|
||||||
|
updated_at: meta.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_ticket_relations(relations: &mut [TicketRelation]) {
|
||||||
|
relations.sort_by(|a, b| {
|
||||||
|
a.ticket_id
|
||||||
|
.cmp(&b.ticket_id)
|
||||||
|
.then_with(|| a.kind.cmp(&b.kind))
|
||||||
|
.then_with(|| a.target.cmp(&b.target))
|
||||||
|
.then_with(|| a.at.cmp(&b.at))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_ticket_record_label(dir: &Path) -> String {
|
||||||
|
dir.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.filter(|name| validate_record_id(name).is_ok())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| "invalid ticket record".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_ticket_record_reason(error: &TicketError) -> &'static str {
|
||||||
|
match error {
|
||||||
|
TicketError::Io { .. } => "could not read ticket record",
|
||||||
|
TicketError::Parse { .. } => "invalid ticket record schema",
|
||||||
|
TicketError::InvalidPathComponent(_) | TicketError::PathEscapesRoot { .. } => {
|
||||||
|
"invalid ticket record identity"
|
||||||
|
}
|
||||||
|
TicketError::Locked { .. } => "ticket backend is locked",
|
||||||
|
TicketError::NotFound(_) => "ticket record is missing",
|
||||||
|
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => {
|
||||||
|
"invalid ticket record metadata"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_invalid_ticket_record(
|
||||||
|
invalid_records: &mut Vec<TicketInvalidRecord>,
|
||||||
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
|
dir: &Path,
|
||||||
|
error: &TicketError,
|
||||||
|
) {
|
||||||
|
let label = invalid_ticket_record_label(dir);
|
||||||
|
if invalid_seen.insert(label.clone()) {
|
||||||
|
invalid_records.push(TicketInvalidRecord {
|
||||||
|
label,
|
||||||
|
reason: invalid_ticket_record_reason(error).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn trim_owned(value: String) -> String {
|
fn trim_owned(value: String) -> String {
|
||||||
value.trim().to_string()
|
value.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
@ -3633,6 +3812,47 @@ state: planning
|
||||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_list_and_show_keep_valid_tickets_when_peer_record_is_invalid() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&tmp);
|
||||||
|
let mut ready = NewTicket::new("Ready Valid");
|
||||||
|
ready.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let valid = backend.create(ready).unwrap();
|
||||||
|
let invalid = backend
|
||||||
|
.create(NewTicket::new("Invalid Secret Title"))
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
backend.root().join(&invalid.id).join("item.md"),
|
||||||
|
"---\ntitle: Invalid Secret Title\nstate: super-secret-invalid\n---\nbody\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(backend.list(TicketFilter::all()).is_err());
|
||||||
|
|
||||||
|
let partial = backend.list_partial(TicketFilter::all()).unwrap();
|
||||||
|
assert_eq!(partial.tickets.len(), 1);
|
||||||
|
assert_eq!(partial.tickets[0].id, valid.id);
|
||||||
|
assert_eq!(partial.invalid_records.len(), 1);
|
||||||
|
assert_eq!(partial.invalid_records[0].label, invalid.id);
|
||||||
|
assert_eq!(
|
||||||
|
partial.invalid_records[0].reason,
|
||||||
|
"invalid ticket record schema"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!partial.invalid_records[0]
|
||||||
|
.reason
|
||||||
|
.contains("super-secret-invalid")
|
||||||
|
);
|
||||||
|
|
||||||
|
let detail = backend
|
||||||
|
.show_partial(TicketIdOrSlug::Id(valid.id.clone()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(detail.ticket.meta.title, "Ready Valid");
|
||||||
|
assert_eq!(detail.invalid_records.len(), 1);
|
||||||
|
assert_eq!(detail.invalid_records[0].label, invalid.id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_uses_configured_japanese_record_language_for_generated_defaults() {
|
fn create_uses_configured_japanese_record_language_for_generated_defaults() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -958,6 +958,10 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
|
||||||
kind: "ticket",
|
kind: "ticket",
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
},
|
},
|
||||||
|
PanelRowKey::InvalidTicket(label) => PanelE2eRowKey {
|
||||||
|
kind: "invalid_ticket",
|
||||||
|
id: label.clone(),
|
||||||
|
},
|
||||||
PanelRowKey::TicketIntakePod {
|
PanelRowKey::TicketIntakePod {
|
||||||
ticket_id,
|
ticket_id,
|
||||||
pod_name,
|
pod_name,
|
||||||
|
|
@ -1359,7 +1363,9 @@ impl MultiPodApp {
|
||||||
),
|
),
|
||||||
None => match &hit.key {
|
None => match &hit.key {
|
||||||
PanelRowKey::Pod(name) => (name.clone(), None, None),
|
PanelRowKey::Pod(name) => (name.clone(), None, None),
|
||||||
PanelRowKey::Ticket(id) => (id.clone(), None, None),
|
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
|
||||||
|
(id.clone(), None, None)
|
||||||
|
}
|
||||||
PanelRowKey::TicketIntakePod { pod_name, .. } => {
|
PanelRowKey::TicketIntakePod { pod_name, .. } => {
|
||||||
(pod_name.clone(), None, None)
|
(pod_name.clone(), None, None)
|
||||||
}
|
}
|
||||||
|
|
@ -1415,7 +1421,9 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
if let Some(key) = visible.iter().find(|key| match key {
|
if let Some(key) = visible.iter().find(|key| match key {
|
||||||
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
|
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
|
||||||
PanelRowKey::Ticket(_) | PanelRowKey::TicketIntakePod { .. } => true,
|
PanelRowKey::Ticket(_)
|
||||||
|
| PanelRowKey::InvalidTicket(_)
|
||||||
|
| PanelRowKey::TicketIntakePod { .. } => true,
|
||||||
}) {
|
}) {
|
||||||
self.select_panel_key(key.clone());
|
self.select_panel_key(key.clone());
|
||||||
return;
|
return;
|
||||||
|
|
@ -4693,6 +4701,11 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
"Open/attach this Ticket's Intake Pod from the associated row.".to_string()
|
"Open/attach this Ticket's Intake Pod from the associated row.".to_string()
|
||||||
}),
|
}),
|
||||||
|
Some(row) if row.kind == PanelRowKind::InvalidTicket => row
|
||||||
|
.disabled_reason
|
||||||
|
.clone()
|
||||||
|
.or_else(|| row.key_hint.clone())
|
||||||
|
.unwrap_or_else(|| "Invalid Ticket record placeholder has no actions.".to_string()),
|
||||||
_ => "No Pod is selected.".to_string(),
|
_ => "No Pod is selected.".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5262,6 +5275,14 @@ fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remai
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_ticket_detail(row: &PanelRow) -> String {
|
fn panel_ticket_detail(row: &PanelRow) -> String {
|
||||||
|
if row.kind == PanelRowKind::InvalidTicket {
|
||||||
|
let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()];
|
||||||
|
if let Some(reason) = panel_ticket_reason(row) {
|
||||||
|
parts.push(format!("Reason: {reason}"));
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
if row.kind == PanelRowKind::TicketIntakePod {
|
if row.kind == PanelRowKind::TicketIntakePod {
|
||||||
let mut parts = row
|
let mut parts = row
|
||||||
.subtitle
|
.subtitle
|
||||||
|
|
@ -5320,6 +5341,9 @@ fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_detail_style(row: &PanelRow) -> Style {
|
fn ticket_detail_style(row: &PanelRow) -> Style {
|
||||||
|
if row.kind == PanelRowKind::InvalidTicket {
|
||||||
|
return Style::default().fg(Color::Yellow);
|
||||||
|
}
|
||||||
if row
|
if row
|
||||||
.ticket
|
.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -5337,7 +5361,7 @@ fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ticket| ticket.id.clone())
|
.map(|ticket| ticket.id.clone())
|
||||||
.unwrap_or_else(|| match &row.key {
|
.unwrap_or_else(|| match &row.key {
|
||||||
PanelRowKey::Ticket(id) => id.clone(),
|
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
||||||
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(),
|
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(),
|
||||||
PanelRowKey::Pod(name) => name.clone(),
|
PanelRowKey::Pod(name) => name.clone(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use protocol::PodStatus;
|
||||||
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||||
TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
||||||
|
|
@ -182,6 +182,7 @@ impl OrchestratorPanelStatus {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub(crate) enum PanelRowKey {
|
pub(crate) enum PanelRowKey {
|
||||||
Ticket(String),
|
Ticket(String),
|
||||||
|
InvalidTicket(String),
|
||||||
TicketIntakePod { ticket_id: String, pod_name: String },
|
TicketIntakePod { ticket_id: String, pod_name: String },
|
||||||
Pod(String),
|
Pod(String),
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +191,7 @@ impl PanelRowKey {
|
||||||
pub(crate) fn pod_name(&self) -> Option<&str> {
|
pub(crate) fn pod_name(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name),
|
Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name),
|
||||||
Self::Ticket(_) => None,
|
Self::Ticket(_) | Self::InvalidTicket(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +204,7 @@ pub(crate) enum PanelRowKind {
|
||||||
ActiveWork,
|
ActiveWork,
|
||||||
TicketIntakePod,
|
TicketIntakePod,
|
||||||
Pod,
|
Pod,
|
||||||
|
InvalidTicket,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
|
@ -324,12 +326,17 @@ impl PanelRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_ticket_section_row(&self) -> bool {
|
pub(crate) fn is_ticket_section_row(&self) -> bool {
|
||||||
self.is_ticket_action() || matches!(self.kind, PanelRowKind::TicketIntakePod)
|
self.is_ticket_action()
|
||||||
|
|| matches!(
|
||||||
|
self.kind,
|
||||||
|
PanelRowKind::TicketIntakePod | PanelRowKind::InvalidTicket
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_POD_NAME_CHARS: usize = 80;
|
const MAX_POD_NAME_CHARS: usize = 80;
|
||||||
const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3;
|
const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3;
|
||||||
|
const MAX_INVALID_TICKET_PLACEHOLDER_ROWS: usize = 5;
|
||||||
const ORCHESTRATOR_SUFFIX: &str = "-orchestrator";
|
const ORCHESTRATOR_SUFFIX: &str = "-orchestrator";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -589,7 +596,10 @@ fn build_workspace_panel_with_registry_model(
|
||||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language());
|
||||||
match build_ticket_rows(&backend, pods, registry) {
|
match build_ticket_rows(&backend, pods, registry) {
|
||||||
Ok(rows) => model.rows.extend(rows),
|
Ok(ticket_rows) => {
|
||||||
|
model.rows.extend(ticket_rows.rows);
|
||||||
|
model.header.diagnostics.extend(ticket_rows.diagnostics);
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
model
|
model
|
||||||
.header
|
.header
|
||||||
|
|
@ -663,25 +673,48 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct TicketRowsBuild {
|
||||||
|
rows: Vec<PanelRow>,
|
||||||
|
diagnostics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn build_ticket_rows(
|
fn build_ticket_rows(
|
||||||
backend: &LocalTicketBackend,
|
backend: &LocalTicketBackend,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
) -> ticket::Result<Vec<PanelRow>> {
|
) -> ticket::Result<TicketRowsBuild> {
|
||||||
|
let partial = backend.list_partial(TicketFilter::all())?;
|
||||||
let mut ticket_rows = Vec::new();
|
let mut ticket_rows = Vec::new();
|
||||||
for summary in backend.list(TicketFilter::all())? {
|
let mut invalid_records = partial.invalid_records;
|
||||||
|
for summary in partial.tickets {
|
||||||
if summary.workflow_state == TicketWorkflowState::Closed {
|
if summary.workflow_state == TicketWorkflowState::Closed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
|
match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) {
|
||||||
|
Ok(ticket) => {
|
||||||
|
let current_ticket_invalid = ticket
|
||||||
|
.invalid_records
|
||||||
|
.iter()
|
||||||
|
.any(|record| record.label == summary.id);
|
||||||
|
invalid_records.extend(ticket.invalid_records);
|
||||||
|
if current_ticket_invalid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ticket_rows.push(ticket_row(
|
ticket_rows.push(ticket_row(
|
||||||
summary,
|
summary,
|
||||||
&ticket.events,
|
&ticket.ticket.events,
|
||||||
&ticket.relations.blockers,
|
&ticket.ticket.relations.blockers,
|
||||||
pods,
|
pods,
|
||||||
registry,
|
registry,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Err(_) => invalid_records.push(TicketInvalidRecord {
|
||||||
|
label: summary.id,
|
||||||
|
reason: "could not load ticket detail".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
ticket_rows.sort_by(|a, b| {
|
ticket_rows.sort_by(|a, b| {
|
||||||
a.priority
|
a.priority
|
||||||
.cmp(&b.priority)
|
.cmp(&b.priority)
|
||||||
|
|
@ -695,7 +728,72 @@ fn build_ticket_rows(
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
rows.extend(intake_rows);
|
rows.extend(intake_rows);
|
||||||
}
|
}
|
||||||
Ok(rows)
|
|
||||||
|
let invalid_records = dedupe_invalid_ticket_records(invalid_records);
|
||||||
|
let diagnostics = invalid_ticket_diagnostics(invalid_records.len());
|
||||||
|
rows.extend(invalid_ticket_rows(&invalid_records));
|
||||||
|
|
||||||
|
Ok(TicketRowsBuild { rows, diagnostics })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dedupe_invalid_ticket_records(records: Vec<TicketInvalidRecord>) -> Vec<TicketInvalidRecord> {
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for record in records {
|
||||||
|
if deduped
|
||||||
|
.iter()
|
||||||
|
.any(|existing: &TicketInvalidRecord| existing.label == record.label)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deduped.push(record);
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_ticket_diagnostics(invalid_count: usize) -> Vec<String> {
|
||||||
|
if invalid_count == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let suffix = if invalid_count > MAX_INVALID_TICKET_PLACEHOLDER_ROWS {
|
||||||
|
format!(
|
||||||
|
"; showing first {} placeholder rows",
|
||||||
|
MAX_INVALID_TICKET_PLACEHOLDER_ROWS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
vec![bounded_panel_diagnostic(format!(
|
||||||
|
"Ticket records partially loaded: {invalid_count} invalid record(s) unavailable for actions{suffix}."
|
||||||
|
))]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_ticket_rows(records: &[TicketInvalidRecord]) -> Vec<PanelRow> {
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.take(MAX_INVALID_TICKET_PLACEHOLDER_ROWS)
|
||||||
|
.map(invalid_ticket_row)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_ticket_row(record: &TicketInvalidRecord) -> PanelRow {
|
||||||
|
PanelRow {
|
||||||
|
key: PanelRowKey::InvalidTicket(record.label.clone()),
|
||||||
|
kind: PanelRowKind::InvalidTicket,
|
||||||
|
title: format!("Invalid Ticket record: {}", record.label),
|
||||||
|
subtitle: Some(record.reason.clone()),
|
||||||
|
status: "invalid".to_string(),
|
||||||
|
priority: ActionPriority::Background,
|
||||||
|
next_action: None,
|
||||||
|
ticket: None,
|
||||||
|
related_pods: Vec::new(),
|
||||||
|
disabled_reason: Some(
|
||||||
|
"Invalid Ticket record is diagnostics-only; lifecycle actions are disabled."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
key_hint: Some(
|
||||||
|
"Actions unavailable until the Ticket record is repaired manually.".to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_row(
|
fn ticket_row(
|
||||||
|
|
@ -1210,6 +1308,179 @@ mod tests {
|
||||||
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
let mut ready_input = NewTicket::new("Ready Still Queueable");
|
||||||
|
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let ready = backend.create(ready_input).unwrap();
|
||||||
|
backend
|
||||||
|
.create(NewTicket::new("Planning Still Clarifies"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for index in 0..6 {
|
||||||
|
let ticket = backend
|
||||||
|
.create(NewTicket::new(format!("Leaked Secret Invalid {index}")))
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
temp.path()
|
||||||
|
.join(".yoi/tickets")
|
||||||
|
.join(&ticket.id)
|
||||||
|
.join("item.md"),
|
||||||
|
format!(
|
||||||
|
"---\ntitle: Leaked Secret Invalid {index}\nstate: super-secret-invalid-{index}\n---\nbody\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry = PanelRegistryStore::from_root(temp.path().join("registry"));
|
||||||
|
registry
|
||||||
|
.claim_ticket(&ready.id, None, "ready-intake", "intake")
|
||||||
|
.unwrap();
|
||||||
|
let model = build_workspace_panel_with_registry(
|
||||||
|
temp.path(),
|
||||||
|
&live_pods(&["ready-intake"]),
|
||||||
|
®istry.snapshot().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ready_index = model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.position(|row| row.title == "Ready Still Queueable")
|
||||||
|
.unwrap();
|
||||||
|
let ready_row = &model.rows[ready_index];
|
||||||
|
assert_eq!(ready_row.next_action, Some(NextUserAction::Queue));
|
||||||
|
assert!(ready_row.is_ticket_action());
|
||||||
|
assert_eq!(
|
||||||
|
model.rows[ready_index + 1].key,
|
||||||
|
PanelRowKey::TicketIntakePod {
|
||||||
|
ticket_id: ready.id.clone(),
|
||||||
|
pod_name: "ready-intake".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let planning = model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.title == "Planning Still Clarifies")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(planning.next_action, Some(NextUserAction::Clarify));
|
||||||
|
assert!(planning.is_ticket_action());
|
||||||
|
|
||||||
|
let invalid_rows = model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.kind == PanelRowKind::InvalidTicket)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(invalid_rows.len(), MAX_INVALID_TICKET_PLACEHOLDER_ROWS);
|
||||||
|
for row in invalid_rows {
|
||||||
|
assert_eq!(row.status, "invalid");
|
||||||
|
assert!(row.ticket.is_none());
|
||||||
|
assert_eq!(row.next_action, None);
|
||||||
|
assert!(!row.is_ticket_action());
|
||||||
|
assert!(row.disabled_reason.as_deref().unwrap().contains("disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let diagnostics = model.header.diagnostics.join("\n");
|
||||||
|
assert!(diagnostics.contains("Ticket records partially loaded: 6 invalid record"));
|
||||||
|
assert!(diagnostics.contains("showing first 5"));
|
||||||
|
assert!(!diagnostics.contains("super-secret-invalid"));
|
||||||
|
assert!(
|
||||||
|
!model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.title.contains("Leaked Secret Invalid"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
let mut corrupt_input = NewTicket::new("Ready With Corrupt Relations");
|
||||||
|
corrupt_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let corrupt = backend.create(corrupt_input).unwrap();
|
||||||
|
let mut other_input = NewTicket::new("Other Ready Still Queueable");
|
||||||
|
other_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let other = backend.create(other_input).unwrap();
|
||||||
|
|
||||||
|
let artifacts = temp
|
||||||
|
.path()
|
||||||
|
.join(".yoi/tickets")
|
||||||
|
.join(&corrupt.id)
|
||||||
|
.join("artifacts");
|
||||||
|
fs::create_dir_all(&artifacts).unwrap();
|
||||||
|
fs::write(artifacts.join("relations.json"), "{").unwrap();
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
|
let corrupt_placeholders = model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.key == PanelRowKey::InvalidTicket(corrupt.id.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(corrupt_placeholders.len(), 1);
|
||||||
|
let corrupt_placeholder = corrupt_placeholders[0];
|
||||||
|
assert_eq!(corrupt_placeholder.kind, PanelRowKind::InvalidTicket);
|
||||||
|
assert_eq!(corrupt_placeholder.next_action, None);
|
||||||
|
assert!(corrupt_placeholder.ticket.is_none());
|
||||||
|
assert!(!corrupt_placeholder.is_ticket_action());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.key == PanelRowKey::Ticket(corrupt.id.clone()))
|
||||||
|
);
|
||||||
|
|
||||||
|
let other_row = model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.key == PanelRowKey::Ticket(other.id.clone()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(other_row.next_action, Some(NextUserAction::Queue));
|
||||||
|
assert!(other_row.is_ticket_action());
|
||||||
|
|
||||||
|
let diagnostics = model.header.diagnostics.join("\n");
|
||||||
|
assert!(diagnostics.contains("Ticket records partially loaded: 1 invalid record"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_keeps_backend_config_unusable_as_whole_ticket_degradation() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let config_dir = temp.path().join(".yoi");
|
||||||
|
fs::create_dir_all(&config_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
config_dir.join("ticket.config.toml"),
|
||||||
|
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
||||||
|
|
||||||
|
let diagnostics = model.header.diagnostics.join("\n");
|
||||||
|
assert!(diagnostics.contains("Ticket config is unusable"));
|
||||||
|
assert!(
|
||||||
|
model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.all(|row| row.kind != PanelRowKind::InvalidTicket)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model.composer.available_targets,
|
||||||
|
vec![ComposerTarget::Companion]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.key == PanelRowKey::Pod("idle".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user