|
|
|
|
@@ -36,7 +36,7 @@ pub struct ExplainResult {
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub related: Option<RelatedEntities>,
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub timeline_excerpt: Option<TimelineExcerpt>,
|
|
|
|
|
pub timeline_excerpt: Option<Vec<TimelineEventSummary>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
@@ -52,7 +52,6 @@ pub struct EntitySummary {
|
|
|
|
|
pub created_at: String,
|
|
|
|
|
pub updated_at: String,
|
|
|
|
|
pub url: Option<String>,
|
|
|
|
|
pub project_path: String,
|
|
|
|
|
pub status_name: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -81,8 +80,6 @@ pub struct OpenThread {
|
|
|
|
|
pub started_at: String,
|
|
|
|
|
pub note_count: usize,
|
|
|
|
|
pub last_note_at: String,
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub first_note_excerpt: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
@@ -104,16 +101,7 @@ pub struct RelatedEntityInfo {
|
|
|
|
|
pub entity_type: String,
|
|
|
|
|
pub iid: i64,
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
pub state: Option<String>,
|
|
|
|
|
pub reference_type: String,
|
|
|
|
|
pub direction: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct TimelineExcerpt {
|
|
|
|
|
pub events: Vec<TimelineEventSummary>,
|
|
|
|
|
pub total_events: usize,
|
|
|
|
|
pub truncated: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
@@ -230,7 +218,6 @@ fn find_explain_issue(
|
|
|
|
|
created_at: ms_to_iso(r.created_at),
|
|
|
|
|
updated_at: ms_to_iso(r.updated_at),
|
|
|
|
|
url: r.web_url,
|
|
|
|
|
project_path: project_path.clone(),
|
|
|
|
|
status_name: r.status_name,
|
|
|
|
|
};
|
|
|
|
|
Ok((summary, local_id, project_path))
|
|
|
|
|
@@ -309,7 +296,6 @@ fn find_explain_mr(
|
|
|
|
|
created_at: ms_to_iso(r.created_at),
|
|
|
|
|
updated_at: ms_to_iso(r.updated_at),
|
|
|
|
|
url: r.web_url,
|
|
|
|
|
project_path: project_path.clone(),
|
|
|
|
|
status_name: None,
|
|
|
|
|
};
|
|
|
|
|
Ok((summary, local_id, project_path))
|
|
|
|
|
@@ -399,17 +385,15 @@ fn truncate_description(desc: Option<&str>, max_len: usize) -> String {
|
|
|
|
|
pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainResult> {
|
|
|
|
|
let project_filter = params.project.as_deref();
|
|
|
|
|
|
|
|
|
|
let (entity_summary, entity_local_id, _project_path, description, created_at_ms) =
|
|
|
|
|
let (entity_summary, entity_local_id, _project_path, description) =
|
|
|
|
|
if params.entity_type == "issues" {
|
|
|
|
|
let (summary, local_id, path) = find_explain_issue(conn, params.iid, project_filter)?;
|
|
|
|
|
let desc = get_issue_description(conn, local_id)?;
|
|
|
|
|
let created_at_ms = get_issue_created_at(conn, local_id)?;
|
|
|
|
|
(summary, local_id, path, desc, created_at_ms)
|
|
|
|
|
(summary, local_id, path, desc)
|
|
|
|
|
} else {
|
|
|
|
|
let (summary, local_id, path) = find_explain_mr(conn, params.iid, project_filter)?;
|
|
|
|
|
let desc = get_mr_description(conn, local_id)?;
|
|
|
|
|
let created_at_ms = get_mr_created_at(conn, local_id)?;
|
|
|
|
|
(summary, local_id, path, desc, created_at_ms)
|
|
|
|
|
(summary, local_id, path, desc)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let description_excerpt = if should_include(¶ms.sections, "description") {
|
|
|
|
|
@@ -436,7 +420,6 @@ pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainR
|
|
|
|
|
¶ms.entity_type,
|
|
|
|
|
entity_local_id,
|
|
|
|
|
params.since,
|
|
|
|
|
created_at_ms,
|
|
|
|
|
)?)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
@@ -497,24 +480,6 @@ fn get_mr_description(conn: &Connection, mr_id: i64) -> Result<Option<String>> {
|
|
|
|
|
Ok(desc)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_issue_created_at(conn: &Connection, issue_id: i64) -> Result<i64> {
|
|
|
|
|
let ts: i64 = conn.query_row(
|
|
|
|
|
"SELECT created_at FROM issues WHERE id = ?",
|
|
|
|
|
[issue_id],
|
|
|
|
|
|row| row.get(0),
|
|
|
|
|
)?;
|
|
|
|
|
Ok(ts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_mr_created_at(conn: &Connection, mr_id: i64) -> Result<i64> {
|
|
|
|
|
let ts: i64 = conn.query_row(
|
|
|
|
|
"SELECT created_at FROM merge_requests WHERE id = ?",
|
|
|
|
|
[mr_id],
|
|
|
|
|
|row| row.get(0),
|
|
|
|
|
)?;
|
|
|
|
|
Ok(ts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Key-decisions heuristic (Task 2)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
@@ -699,7 +664,6 @@ fn build_activity_summary(
|
|
|
|
|
entity_type: &str,
|
|
|
|
|
entity_id: i64,
|
|
|
|
|
since: Option<i64>,
|
|
|
|
|
created_at_ms: i64,
|
|
|
|
|
) -> Result<ActivitySummary> {
|
|
|
|
|
let id_col = id_column_for(entity_type);
|
|
|
|
|
|
|
|
|
|
@@ -738,14 +702,11 @@ fn build_activity_summary(
|
|
|
|
|
})?;
|
|
|
|
|
let notes = notes_count as usize;
|
|
|
|
|
|
|
|
|
|
// Floor first_event at created_at — label events can predate entity creation
|
|
|
|
|
// due to bulk operations or API imports
|
|
|
|
|
let first_event = [state_min, label_min, note_min]
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.flatten()
|
|
|
|
|
.min()
|
|
|
|
|
.map(|ts| ts.max(created_at_ms));
|
|
|
|
|
.min();
|
|
|
|
|
let last_event = [state_max, label_max, note_max]
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
@@ -779,10 +740,7 @@ fn fetch_open_threads(
|
|
|
|
|
WHERE n2.discussion_id = d.id AND n2.is_system = 0) AS note_count, \
|
|
|
|
|
(SELECT n3.author_username FROM notes n3 \
|
|
|
|
|
WHERE n3.discussion_id = d.id \
|
|
|
|
|
ORDER BY n3.created_at ASC LIMIT 1) AS started_by, \
|
|
|
|
|
(SELECT SUBSTR(n4.body, 1, 200) FROM notes n4 \
|
|
|
|
|
WHERE n4.discussion_id = d.id AND n4.is_system = 0 \
|
|
|
|
|
ORDER BY n4.created_at ASC LIMIT 1) AS first_note_body \
|
|
|
|
|
ORDER BY n3.created_at ASC LIMIT 1) AS started_by \
|
|
|
|
|
FROM discussions d \
|
|
|
|
|
WHERE d.{id_col} = ?1 \
|
|
|
|
|
AND d.resolvable = 1 \
|
|
|
|
|
@@ -794,14 +752,12 @@ fn fetch_open_threads(
|
|
|
|
|
let threads = stmt
|
|
|
|
|
.query_map([entity_id], |row| {
|
|
|
|
|
let count: i64 = row.get(3)?;
|
|
|
|
|
let first_note_body: Option<String> = row.get(5)?;
|
|
|
|
|
Ok(OpenThread {
|
|
|
|
|
discussion_id: row.get(0)?,
|
|
|
|
|
started_at: ms_to_iso(row.get::<_, i64>(1)?),
|
|
|
|
|
last_note_at: ms_to_iso(row.get::<_, i64>(2)?),
|
|
|
|
|
note_count: count as usize,
|
|
|
|
|
started_by: row.get(4)?,
|
|
|
|
|
first_note_excerpt: first_note_body,
|
|
|
|
|
})
|
|
|
|
|
})?
|
|
|
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
|
@@ -857,18 +813,15 @@ fn fetch_related_entities(
|
|
|
|
|
|
|
|
|
|
// Outgoing references (excluding closes, shown above).
|
|
|
|
|
// Filter out unresolved refs (NULL target_entity_iid) to avoid rusqlite type errors.
|
|
|
|
|
// Excludes self-references (same type + same local ID).
|
|
|
|
|
let mut out_stmt = conn.prepare(
|
|
|
|
|
"SELECT er.target_entity_type, er.target_entity_iid, er.reference_type, \
|
|
|
|
|
COALESCE(i.title, mr.title) as title, \
|
|
|
|
|
COALESCE(i.state, mr.state) as state \
|
|
|
|
|
COALESCE(i.title, mr.title) as title \
|
|
|
|
|
FROM entity_references er \
|
|
|
|
|
LEFT JOIN issues i ON er.target_entity_type = 'issue' AND i.id = er.target_entity_id \
|
|
|
|
|
LEFT JOIN merge_requests mr ON er.target_entity_type = 'merge_request' AND mr.id = er.target_entity_id \
|
|
|
|
|
WHERE er.source_entity_type = ?1 AND er.source_entity_id = ?2 \
|
|
|
|
|
AND er.reference_type != 'closes' \
|
|
|
|
|
AND er.target_entity_iid IS NOT NULL \
|
|
|
|
|
AND NOT (er.target_entity_type = ?1 AND er.target_entity_id = ?2) \
|
|
|
|
|
ORDER BY er.target_entity_type, er.target_entity_iid",
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
@@ -879,26 +832,21 @@ fn fetch_related_entities(
|
|
|
|
|
iid: row.get(1)?,
|
|
|
|
|
reference_type: row.get(2)?,
|
|
|
|
|
title: row.get(3)?,
|
|
|
|
|
state: row.get(4)?,
|
|
|
|
|
direction: "outgoing".to_string(),
|
|
|
|
|
})
|
|
|
|
|
})?
|
|
|
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
|
|
|
|
|
|
// Incoming references (excluding closes).
|
|
|
|
|
// COALESCE(i.iid, mr.iid) can be NULL if the source entity was deleted; filter those out.
|
|
|
|
|
// Excludes self-references (same type + same local ID).
|
|
|
|
|
let mut in_stmt = conn.prepare(
|
|
|
|
|
"SELECT er.source_entity_type, COALESCE(i.iid, mr.iid) as iid, er.reference_type, \
|
|
|
|
|
COALESCE(i.title, mr.title) as title, \
|
|
|
|
|
COALESCE(i.state, mr.state) as state \
|
|
|
|
|
COALESCE(i.title, mr.title) as title \
|
|
|
|
|
FROM entity_references er \
|
|
|
|
|
LEFT JOIN issues i ON er.source_entity_type = 'issue' AND i.id = er.source_entity_id \
|
|
|
|
|
LEFT JOIN merge_requests mr ON er.source_entity_type = 'merge_request' AND mr.id = er.source_entity_id \
|
|
|
|
|
WHERE er.target_entity_type = ?1 AND er.target_entity_id = ?2 \
|
|
|
|
|
AND er.reference_type != 'closes' \
|
|
|
|
|
AND COALESCE(i.iid, mr.iid) IS NOT NULL \
|
|
|
|
|
AND NOT (er.source_entity_type = ?1 AND er.source_entity_id = ?2) \
|
|
|
|
|
ORDER BY er.source_entity_type, COALESCE(i.iid, mr.iid)",
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
@@ -909,8 +857,6 @@ fn fetch_related_entities(
|
|
|
|
|
iid: row.get(1)?,
|
|
|
|
|
reference_type: row.get(2)?,
|
|
|
|
|
title: row.get(3)?,
|
|
|
|
|
state: row.get(4)?,
|
|
|
|
|
direction: "incoming".to_string(),
|
|
|
|
|
})
|
|
|
|
|
})?
|
|
|
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
|
@@ -937,17 +883,11 @@ fn build_timeline_excerpt_from_pipeline(
|
|
|
|
|
conn: &Connection,
|
|
|
|
|
entity: &EntitySummary,
|
|
|
|
|
params: &ExplainParams,
|
|
|
|
|
) -> Option<TimelineExcerpt> {
|
|
|
|
|
) -> Option<Vec<TimelineEventSummary>> {
|
|
|
|
|
let timeline_entity_type = match entity.entity_type.as_str() {
|
|
|
|
|
"issue" => "issue",
|
|
|
|
|
"merge_request" => "merge_request",
|
|
|
|
|
_ => {
|
|
|
|
|
return Some(TimelineExcerpt {
|
|
|
|
|
events: vec![],
|
|
|
|
|
total_events: 0,
|
|
|
|
|
truncated: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
_ => return Some(vec![]),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let project_id = params
|
|
|
|
|
@@ -960,43 +900,29 @@ fn build_timeline_excerpt_from_pipeline(
|
|
|
|
|
Ok(result) => result,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("explain: timeline seed failed: {e}");
|
|
|
|
|
return Some(TimelineExcerpt {
|
|
|
|
|
events: vec![],
|
|
|
|
|
total_events: 0,
|
|
|
|
|
truncated: false,
|
|
|
|
|
});
|
|
|
|
|
return Some(vec![]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Request a generous limit from the pipeline — we'll take the tail (most recent)
|
|
|
|
|
let pipeline_limit = 500;
|
|
|
|
|
let (events, _total) = match collect_events(
|
|
|
|
|
let (mut events, _total) = match collect_events(
|
|
|
|
|
conn,
|
|
|
|
|
&seed_result.seed_entities,
|
|
|
|
|
&[],
|
|
|
|
|
&seed_result.evidence_notes,
|
|
|
|
|
&seed_result.matched_discussions,
|
|
|
|
|
params.since,
|
|
|
|
|
pipeline_limit,
|
|
|
|
|
MAX_TIMELINE_EVENTS,
|
|
|
|
|
) {
|
|
|
|
|
Ok(result) => result,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("explain: timeline collect failed: {e}");
|
|
|
|
|
return Some(TimelineExcerpt {
|
|
|
|
|
events: vec![],
|
|
|
|
|
total_events: 0,
|
|
|
|
|
truncated: false,
|
|
|
|
|
});
|
|
|
|
|
return Some(vec![]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let total_events = events.len();
|
|
|
|
|
let truncated = total_events > MAX_TIMELINE_EVENTS;
|
|
|
|
|
events.truncate(MAX_TIMELINE_EVENTS);
|
|
|
|
|
|
|
|
|
|
// Keep the MOST RECENT events — events are sorted ASC by collect_events,
|
|
|
|
|
// so we skip from the front to keep the tail
|
|
|
|
|
let start = total_events.saturating_sub(MAX_TIMELINE_EVENTS);
|
|
|
|
|
let summaries = events[start..]
|
|
|
|
|
let summaries = events
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|e| TimelineEventSummary {
|
|
|
|
|
timestamp: ms_to_iso(e.timestamp),
|
|
|
|
|
@@ -1006,11 +932,7 @@ fn build_timeline_excerpt_from_pipeline(
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Some(TimelineExcerpt {
|
|
|
|
|
events: summaries,
|
|
|
|
|
total_events,
|
|
|
|
|
truncated,
|
|
|
|
|
})
|
|
|
|
|
Some(summaries)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn timeline_event_type_label(event_type: &crate::timeline::TimelineEventType) -> String {
|
|
|
|
|
@@ -1143,11 +1065,8 @@ pub fn print_explain(result: &ExplainResult) {
|
|
|
|
|
Theme::bold().render(&result.entity.title)
|
|
|
|
|
);
|
|
|
|
|
println!(
|
|
|
|
|
" Project: {} State: {} Author: {} Created: {}",
|
|
|
|
|
result.entity.project_path,
|
|
|
|
|
result.entity.state,
|
|
|
|
|
result.entity.author,
|
|
|
|
|
result.entity.created_at
|
|
|
|
|
" State: {} Author: {} Created: {}",
|
|
|
|
|
result.entity.state, result.entity.author, result.entity.created_at
|
|
|
|
|
);
|
|
|
|
|
if !result.entity.assignees.is_empty() {
|
|
|
|
|
println!(" Assignees: {}", result.entity.assignees.join(", "));
|
|
|
|
|
@@ -1222,18 +1141,6 @@ pub fn print_explain(result: &ExplainResult) {
|
|
|
|
|
t.note_count,
|
|
|
|
|
t.last_note_at
|
|
|
|
|
);
|
|
|
|
|
if let Some(ref excerpt) = t.first_note_excerpt {
|
|
|
|
|
let preview = if excerpt.len() > 100 {
|
|
|
|
|
let b = excerpt.floor_char_boundary(100);
|
|
|
|
|
format!("{}...", &excerpt[..b])
|
|
|
|
|
} else {
|
|
|
|
|
excerpt.clone()
|
|
|
|
|
};
|
|
|
|
|
// Show first line only in human output
|
|
|
|
|
if let Some(line) = preview.lines().next() {
|
|
|
|
|
println!(" {}", Theme::muted().render(line));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1252,17 +1159,8 @@ pub fn print_explain(result: &ExplainResult) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for ri in &related.related_issues {
|
|
|
|
|
let state_str = ri
|
|
|
|
|
.state
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map_or(String::new(), |s| format!(" [{s}]"));
|
|
|
|
|
let arrow = if ri.direction == "incoming" {
|
|
|
|
|
"<-"
|
|
|
|
|
} else {
|
|
|
|
|
"->"
|
|
|
|
|
};
|
|
|
|
|
println!(
|
|
|
|
|
" {} {arrow} {} #{} — {}{state_str} ({})",
|
|
|
|
|
" {} {} #{} — {} ({})",
|
|
|
|
|
Icons::info(),
|
|
|
|
|
ri.entity_type,
|
|
|
|
|
ri.iid,
|
|
|
|
|
@@ -1273,25 +1171,16 @@ pub fn print_explain(result: &ExplainResult) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Timeline excerpt
|
|
|
|
|
if let Some(ref excerpt) = result.timeline_excerpt
|
|
|
|
|
&& !excerpt.events.is_empty()
|
|
|
|
|
if let Some(ref events) = result.timeline_excerpt
|
|
|
|
|
&& !events.is_empty()
|
|
|
|
|
{
|
|
|
|
|
let truncation_note = if excerpt.truncated {
|
|
|
|
|
format!(
|
|
|
|
|
" (showing {} of {})",
|
|
|
|
|
excerpt.events.len(),
|
|
|
|
|
excerpt.total_events
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
println!(
|
|
|
|
|
"\n{} {}{}",
|
|
|
|
|
"\n{} {} ({} events)",
|
|
|
|
|
Icons::info(),
|
|
|
|
|
Theme::bold().render("Timeline"),
|
|
|
|
|
truncation_note
|
|
|
|
|
events.len()
|
|
|
|
|
);
|
|
|
|
|
for e in &excerpt.events {
|
|
|
|
|
for e in events {
|
|
|
|
|
let actor_str = e.actor.as_deref().unwrap_or("");
|
|
|
|
|
println!(
|
|
|
|
|
" {} {} {} {}",
|
|
|
|
|
@@ -1980,8 +1869,7 @@ mod tests {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let activity =
|
|
|
|
|
build_activity_summary(&conn, "issues", issue_id, None, 1_704_067_200_000).unwrap();
|
|
|
|
|
let activity = build_activity_summary(&conn, "issues", issue_id, None).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(activity.state_changes, 2);
|
|
|
|
|
assert_eq!(activity.label_changes, 1);
|
|
|
|
|
@@ -2016,14 +1904,7 @@ mod tests {
|
|
|
|
|
5_000_000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let activity = build_activity_summary(
|
|
|
|
|
&conn,
|
|
|
|
|
"issues",
|
|
|
|
|
issue_id,
|
|
|
|
|
Some(3_000_000),
|
|
|
|
|
1_704_067_200_000,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let activity = build_activity_summary(&conn, "issues", issue_id, Some(3_000_000)).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(activity.state_changes, 1, "Only the recent event");
|
|
|
|
|
}
|
|
|
|
|
@@ -2079,8 +1960,7 @@ mod tests {
|
|
|
|
|
let (conn, project_id) = setup_explain_db();
|
|
|
|
|
let issue_id = insert_test_issue(&conn, project_id, 64, None);
|
|
|
|
|
|
|
|
|
|
let activity =
|
|
|
|
|
build_activity_summary(&conn, "issues", issue_id, None, 1_704_067_200_000).unwrap();
|
|
|
|
|
let activity = build_activity_summary(&conn, "issues", issue_id, None).unwrap();
|
|
|
|
|
assert_eq!(activity.state_changes, 0);
|
|
|
|
|
assert_eq!(activity.label_changes, 0);
|
|
|
|
|
assert_eq!(activity.notes, 0);
|
|
|
|
|
|