shiplog 0.8.0

CLI evidence compiler for review-cycle packets with receipts, coverage, gaps, and safe share profiles.
Documentation
use anyhow::{Context, Result};
use chrono::Utc;
use serde::Deserialize;
use shiplog::schema::event::EventEnvelope;
use shiplog::schema::workstream::WorkstreamsFile;
mod claims;
mod stats;
mod workstreams;

const MAX_RECEIPTS_PER_WORKSTREAM: usize = 10;

#[derive(Deserialize)]
struct LlmResponse {
    workstreams: Vec<LlmWorkstream>,
}

#[derive(Deserialize)]
pub(super) struct LlmWorkstream {
    pub(super) title: String,
    pub(super) summary: Option<String>,
    #[serde(default)]
    pub(super) tags: Vec<String>,
    pub(super) event_indices: Vec<usize>,
    #[serde(default)]
    pub(super) receipt_indices: Vec<usize>,
}

/// Parse the LLM response payload into a `WorkstreamsFile`.
///
/// - Invalid indices are ignored.
/// - Duplicate claims across workstreams follow first-wins semantics.
/// - Up to 10 receipt IDs are preserved per workstream.
/// - Any unclaimed input events are grouped under `Uncategorized`.
pub fn parse_llm_response(json_str: &str, events: &[EventEnvelope]) -> Result<WorkstreamsFile> {
    let resp: LlmResponse =
        serde_json::from_str(json_str).context("parse LLM clustering response")?;

    let mut claims = claims::ClaimTracker::new(events.len());
    let mut workstreams =
        workstreams::build_claimed_workstreams(resp.workstreams, events, &mut claims);

    if let Some(uncategorized) =
        workstreams::build_uncategorized_workstream(events, &claims.orphan_indices())
    {
        workstreams.push(uncategorized);
    }

    Ok(WorkstreamsFile {
        version: 1,
        generated_at: Utc::now(),
        workstreams,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Utc;
    use shiplog::ids::EventId;
    use shiplog::schema::event::*;

    fn make_pr_event(num: u64) -> EventEnvelope {
        EventEnvelope {
            id: EventId::from_parts(["test", "pr", &num.to_string()]),
            kind: EventKind::PullRequest,
            occurred_at: Utc::now(),
            actor: Actor {
                login: "user".into(),
                id: None,
            },
            repo: RepoRef {
                full_name: "org/repo".into(),
                html_url: None,
                visibility: RepoVisibility::Unknown,
            },
            payload: EventPayload::PullRequest(PullRequestEvent {
                number: num,
                title: format!("PR {num}"),
                state: PullRequestState::Merged,
                created_at: Utc::now(),
                merged_at: Some(Utc::now()),
                additions: Some(10),
                deletions: Some(5),
                changed_files: Some(3),
                touched_paths_hint: vec![],
                window: None,
            }),
            tags: vec![],
            links: vec![],
            source: SourceRef {
                system: SourceSystem::Github,
                url: None,
                opaque_id: None,
            },
        }
    }

    fn make_review_event(num: u64) -> EventEnvelope {
        EventEnvelope {
            id: EventId::from_parts(["test", "review", &num.to_string()]),
            kind: EventKind::Review,
            occurred_at: Utc::now(),
            actor: Actor {
                login: "reviewer".into(),
                id: None,
            },
            repo: RepoRef {
                full_name: "org/repo".into(),
                html_url: None,
                visibility: RepoVisibility::Unknown,
            },
            payload: EventPayload::Review(ReviewEvent {
                pull_number: num,
                pull_title: format!("PR {num}"),
                submitted_at: Utc::now(),
                state: "approved".into(),
                window: None,
            }),
            tags: vec![],
            links: vec![],
            source: SourceRef {
                system: SourceSystem::Github,
                url: None,
                opaque_id: None,
            },
        }
    }

    fn make_manual_event(num: u64) -> EventEnvelope {
        EventEnvelope {
            id: EventId::from_parts(["test", "manual", &num.to_string()]),
            kind: EventKind::Manual,
            occurred_at: Utc::now(),
            actor: Actor {
                login: "user".into(),
                id: None,
            },
            repo: RepoRef {
                full_name: "org/repo".into(),
                html_url: None,
                visibility: RepoVisibility::Unknown,
            },
            payload: EventPayload::Manual(ManualEvent {
                event_type: ManualEventType::Note,
                title: format!("Manual {num}"),
                description: None,
                started_at: None,
                ended_at: None,
                impact: None,
            }),
            tags: vec![],
            links: vec![],
            source: SourceRef {
                system: SourceSystem::Manual,
                url: None,
                opaque_id: None,
            },
        }
    }

    #[test]
    fn mixed_event_types_stats_counted_exactly() {
        let events = vec![make_pr_event(1), make_review_event(2), make_manual_event(3)];

        let json = serde_json::json!({
            "workstreams": [{
                "title": "Mixed",
                "summary": "All types",
                "tags": [],
                "event_indices": [0, 1, 2],
                "receipt_indices": [0]
            }]
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        assert_eq!(result.workstreams.len(), 1);
        let ws = &result.workstreams[0];
        assert_eq!(ws.stats.pull_requests, 1, "PR count must be exactly 1");
        assert_eq!(ws.stats.reviews, 1, "review count must be exactly 1");
        assert_eq!(ws.stats.manual_events, 1, "manual count must be exactly 1");
    }

    #[test]
    fn index_at_events_len_is_skipped() {
        let events = vec![make_pr_event(1)];

        let json = serde_json::json!({
            "workstreams": [{
                "title": "Boundary",
                "summary": "test",
                "tags": [],
                "event_indices": [1],
                "receipt_indices": []
            }]
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        assert_eq!(result.workstreams.len(), 1);
        assert_eq!(result.workstreams[0].title, "Uncategorized");
        assert_eq!(result.workstreams[0].events.len(), 1);
    }

    #[test]
    fn orphan_receipts_capped_at_10() {
        let events: Vec<EventEnvelope> = (0..15).map(make_pr_event).collect();

        let json = serde_json::json!({
            "workstreams": []
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        assert_eq!(result.workstreams.len(), 1);
        let orphan_ws = &result.workstreams[0];
        assert_eq!(orphan_ws.title, "Uncategorized");
        assert_eq!(orphan_ws.events.len(), 15);
        assert_eq!(orphan_ws.receipts.len(), 10, "orphan receipts capped at 10");
    }

    #[test]
    fn orphan_stats_count_mixed_types() {
        let events = vec![make_pr_event(1), make_review_event(2), make_manual_event(3)];

        let json = serde_json::json!({
            "workstreams": []
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        assert_eq!(result.workstreams.len(), 1);
        let ws = &result.workstreams[0];
        assert_eq!(ws.stats.pull_requests, 1);
        assert_eq!(ws.stats.reviews, 1);
        assert_eq!(ws.stats.manual_events, 1);
    }

    #[test]
    fn duplicate_index_claimed_only_once() {
        let events = vec![make_pr_event(1), make_pr_event(2)];

        let json = serde_json::json!({
            "workstreams": [
                {
                    "title": "First",
                    "event_indices": [0, 1],
                    "receipt_indices": []
                },
                {
                    "title": "Second",
                    "event_indices": [0, 1],
                    "receipt_indices": []
                }
            ]
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        assert_eq!(result.workstreams.len(), 1);
        assert_eq!(result.workstreams[0].title, "First");
        assert_eq!(result.workstreams[0].events.len(), 2);
    }

    #[test]
    fn empty_events_no_workstreams() {
        let json = serde_json::json!({
            "workstreams": [{
                "title": "Empty",
                "event_indices": [0],
                "receipt_indices": []
            }]
        });

        let result = parse_llm_response(&json.to_string(), &[]).unwrap();
        assert_eq!(result.workstreams.len(), 0);
    }

    #[test]
    fn receipt_indices_filtered_to_valid_events() {
        let events = vec![make_pr_event(1), make_pr_event(2)];

        let json = serde_json::json!({
            "workstreams": [{
                "title": "Receipts",
                "event_indices": [0],
                "receipt_indices": [0, 1]
            }]
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        let ws = &result.workstreams[0];
        assert_eq!(ws.receipts.len(), 1);
        assert_eq!(result.workstreams.len(), 2);
        assert_eq!(result.workstreams[1].title, "Uncategorized");
    }

    #[test]
    fn receipt_indices_capped_at_10_for_claimed_workstream() {
        let events: Vec<EventEnvelope> = (0..15).map(make_pr_event).collect();
        let indices: Vec<usize> = (0..15).collect();

        let json = serde_json::json!({
            "workstreams": [{
                "title": "Many receipts",
                "event_indices": indices,
                "receipt_indices": indices
            }]
        });

        let result = parse_llm_response(&json.to_string(), &events).unwrap();
        assert_eq!(result.workstreams.len(), 1);
        assert_eq!(result.workstreams[0].receipts.len(), 10);
    }
}