a73x

src/timeline.rs

Ref:   Size: 19.8 KiB   History

//! The unified event timeline for a patch: one sequence, every kind of event.
//!
//! `patch log` lists revisions. `patch show` lists comments and reviews. Between
//! them they hold everything that happened to a patch and answer, separately,
//! two questions nobody asks. The question a reviewer actually has is *what
//! answered what* — a comment on revision 1 followed by revision 2 is a causal
//! chain, and it is the chain this project's revision anchoring exists to
//! record. Reconstructing it meant reading two commands' output and interleaving
//! them by timestamp by eye.
//!
//! # Where the ordering comes from
//!
//! Events are ordered by the DAG walk, then stable-sorted by Lamport clock.
//! Both halves of that matter:
//!
//! - The walk is `Sort::TOPOLOGICAL | Sort::REVERSE`, the same order
//!   `PatchState::from_ref_uncached` folds in, which is what guarantees this
//!   view can never number a revision differently from `patch log`.
//! - Clocks are monotonic along parent links, so for the linear DAG every patch
//!   in the wild actually has, sorting by clock changes nothing. Where it earns
//!   its keep is a DAG joined from a fork: topological order emits one branch
//!   then the other, and the clock interleaves them back into the order things
//!   happened.
//!
//! Sorting by `(clock, oid)` — the total order the status fold resolves
//! conflicts with — was the obvious alternative and is wrong here. Events
//! written before clocks existed all carry clock 0, so the oid tiebreak would
//! sort a legacy patch's whole history into hash order. A *stable* sort keyed on
//! the clock alone leaves those events in the only order that is meaningful for
//! them, the one the DAG's parent links record.
//!
//! # Where the content comes from
//!
//! From `PatchState`, not from the raw events: revision numbering, the
//! per-`(author, revision)` vote rule, and every correction have already been
//! resolved there. Re-deriving any of it here would be a second implementation
//! of the same rules, free to drift from the first — and the first is the one
//! `patch show` prints. The walk supplies ordering and the correction events;
//! everything a reader sees is joined onto it by event OID.
//!
//! # Reads do not write
//!
//! Building a timeline appends no event and moves no ref. It resolves the
//! patch's ref, walks it, and folds it — including a patch still in the
//! pre-revision-refs layout, which resolves and walks where it lies rather
//! than being migrated on the way past. (Note `patch show` additionally moves
//! a `refs/collab/local/seen/` read-marker — the timeline deliberately does
//! *not* adopt that pattern and marks nothing read.)

use git2::Repository;
use serde::Serialize;

use crate::error::Error;
use crate::event::{Action, Author};
use crate::state::{self, PatchState};

/// One thing that happened to a patch.
///
/// `revision` is the anchor that makes the sequence causal rather than merely
/// chronological: the revision a comment or review was made against, or the
/// revision a revision entry *is*. `None` only where the event is not about a
/// revision at all (a label, a correction, the merge).
#[derive(Debug, Clone, Serialize)]
pub struct Entry {
    #[serde(flatten)]
    pub kind: Kind,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub revision: Option<u32>,
    pub author: Author,
    pub timestamp: String,
    /// The event's own OID, so a caller can name it to `edit-comment`.
    pub id: String,
}

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Kind {
    Revision {
        commit: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        body: Option<String>,
        edited: bool,
    },
    Comment {
        body: String,
        edited: bool,
        deleted: bool,
    },
    InlineComment {
        file: String,
        line: u32,
        body: String,
        edited: bool,
        deleted: bool,
    },
    Review {
        verdict: String,
        body: String,
        edited: bool,
    },
    Label {
        label: String,
    },
    Unlabel {
        label: String,
    },
    /// A `BodyEdit` that the fold honoured, naming what it corrected.
    Edited {
        target: String,
    },
    /// A `CommentDelete` that the fold honoured.
    Deleted {
        target: String,
    },
    /// A claim that an inline comment was answered.
    ///
    /// Every one is listed, including a claim that later lost the
    /// `(clock, oid)` race to a reopen. This is the one place the timeline
    /// deliberately shows more than the standing state: "the author said this
    /// was fixed and the reviewer disagreed" is the history the patch model
    /// exists to keep, and showing only the winner would collapse it back into
    /// the lost state transition a force-push produces.
    ///
    /// That is also why there is no `authorized` filter here, unlike `Edited`
    /// and `Deleted`. Those are hidden when refused because they never took
    /// effect on anyone's words; a resolution is a signed claim by its own
    /// author, and the fold honours it whoever wrote it.
    Resolved {
        target: String,
    },
    /// A withdrawal of a resolution.
    ///
    /// Named for the CLI verb (`patch unresolve`) rather than "reopened",
    /// which this timeline already uses for a patch being reopened. Two
    /// unrelated things sharing one word in the same column is how a reader
    /// concludes the patch was reopened when a comment was.
    CommentUnresolved {
        target: String,
    },
    Closed {
        #[serde(skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
    Merged {
        #[serde(skip_serializing_if = "Option::is_none")]
        commit: Option<String>,
    },
    /// A `PatchReopen`. Listed for the same reason `Closed` and `Merged` are:
    /// a timeline that ended at "merged" while the patch reads `open` would be
    /// the one view of a patch that disagrees with every other.
    Reopened,
}

impl Kind {
    /// The fixed-width label opening a rendered line.
    fn label(&self, revision: Option<u32>) -> String {
        match self {
            Kind::Revision { .. } => format!("r{}", revision.unwrap_or(0)),
            Kind::Comment { .. } => "comment".to_string(),
            Kind::InlineComment { .. } => "inline".to_string(),
            Kind::Review { .. } => "review".to_string(),
            Kind::Label { .. } => "label".to_string(),
            Kind::Unlabel { .. } => "unlabel".to_string(),
            Kind::Edited { .. } => "edited".to_string(),
            Kind::Deleted { .. } => "deleted".to_string(),
            Kind::Resolved { .. } => "resolved".to_string(),
            Kind::CommentUnresolved { .. } => "unresolved".to_string(),
            Kind::Closed { .. } => "closed".to_string(),
            Kind::Merged { .. } => "merged".to_string(),
            Kind::Reopened => "reopened".to_string(),
        }
    }
}

/// Build the timeline for the patch `id_prefix` resolves to.
pub fn build(repo: &Repository, id_prefix: &str) -> Result<(PatchState, Vec<Entry>), Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let patch = PatchState::from_ref(repo, &ref_name, &id)?;
    let events = crate::dag::walk_events(repo, &ref_name)?;

    // Who wrote each body-carrying event, so a correction naming one can be
    // held to the same rule the fold applies: only the author of a body may
    // correct it. Everything else is dropped in silence, exactly as
    // `BodyOverrides::authorized` drops it — a timeline that listed a refused
    // edit would be reporting a rewrite of someone else's words that never
    // took effect.
    let mut owners: std::collections::HashMap<String, String> = std::collections::HashMap::new();
    for (oid, event) in &events {
        if carries_a_body(&event.action) {
            owners.insert(oid.to_string(), event.author.email.clone());
        }
    }

    // Derived content, indexed by the event that produced it. Joining on the
    // OID is what keeps this view's text identical to `patch show`'s.
    let comments: std::collections::HashMap<String, &state::Comment> = patch
        .comments
        .iter()
        .map(|c| (c.commit_id.to_string(), c))
        .collect();
    let inline: std::collections::HashMap<String, &state::InlineComment> = patch
        .inline_comments
        .iter()
        .map(|c| (c.commit_id.to_string(), c))
        .collect();
    let reviews: std::collections::HashMap<String, &state::Review> = patch
        .reviews
        .iter()
        .map(|r| (r.commit_id.to_string(), r))
        .collect();
    let revisions: std::collections::HashMap<String, &state::Revision> = patch
        .revisions
        .iter()
        .filter(|r| !r.event_id.is_empty())
        .map(|r| (r.event_id.clone(), r))
        .collect();

    let mut entries: Vec<(u64, Entry)> = Vec::new();

    for (oid, event) in &events {
        let oid_hex = oid.to_string();
        let author = event.author.clone();
        let timestamp = event.timestamp.clone();

        let (kind, revision) = match &event.action {
            Action::PatchCreate { .. }
            | Action::PatchRevision { .. }
            | Action::PatchRevise { .. } => {
                // A revision the fold discarded as a duplicate commit has no
                // entry here, which is right: it added nothing to the patch.
                let Some(rev) = revisions.get(&oid_hex) else {
                    continue;
                };
                (
                    Kind::Revision {
                        commit: rev.commit.clone(),
                        body: rev.body.clone(),
                        edited: rev.edited,
                    },
                    Some(rev.number),
                )
            }
            Action::PatchComment { .. } => {
                let Some(c) = comments.get(&oid_hex) else {
                    continue;
                };
                (
                    Kind::Comment {
                        body: c.body.clone(),
                        edited: c.edited,
                        deleted: c.deleted,
                    },
                    // Anchored below, once the sequence is in order: a thread
                    // comment carries no revision of its own.
                    None,
                )
            }
            Action::PatchInlineComment { .. } => {
                let Some(c) = inline.get(&oid_hex) else {
                    continue;
                };
                (
                    Kind::InlineComment {
                        file: c.file.clone(),
                        line: c.line,
                        body: c.body.clone(),
                        edited: c.edited,
                        deleted: c.deleted,
                    },
                    c.revision,
                )
            }
            Action::PatchReview { .. } => {
                // A superseded vote is gone from `patch show`, so it is gone
                // from here too; the two must not disagree about what stands.
                let Some(r) = reviews.get(&oid_hex) else {
                    continue;
                };
                (
                    Kind::Review {
                        verdict: r.verdict.as_str().to_string(),
                        body: r.body.clone(),
                        edited: r.edited,
                    },
                    // `None` for a review written before reviews were
                    // revision-scoped. It stays unanchored: the anchoring pass
                    // below deliberately only touches thread comments, because
                    // filling in a review's revision from its position is the
                    // attribution that issue 33b5e541 is about.
                    r.revision,
                )
            }
            Action::PatchLabel { label } => (
                Kind::Label {
                    label: label.clone(),
                },
                None,
            ),
            Action::PatchUnlabel { label } => (
                Kind::Unlabel {
                    label: label.clone(),
                },
                None,
            ),
            Action::BodyEdit { target, .. } => {
                if !authorized(&owners, target, &author) {
                    continue;
                }
                (
                    Kind::Edited {
                        target: target.clone(),
                    },
                    None,
                )
            }
            Action::CommentDelete { target } => {
                if !authorized(&owners, target, &author) {
                    continue;
                }
                (
                    Kind::Deleted {
                        target: target.clone(),
                    },
                    None,
                )
            }
            Action::PatchCommentResolve { comment, revision } => (
                Kind::Resolved {
                    target: comment.clone(),
                },
                // The revision the claim was made against, so the sequence
                // reads causally: comment on r1, revision r2, resolved at r2.
                *revision,
            ),
            Action::PatchCommentReopen { comment } => (
                Kind::CommentUnresolved {
                    target: comment.clone(),
                },
                None,
            ),
            Action::PatchClose { reason } => (
                Kind::Closed {
                    reason: reason.clone(),
                },
                None,
            ),
            Action::PatchMerge { commit } => (
                Kind::Merged {
                    commit: (!commit.is_empty()).then(|| commit.clone()),
                },
                None,
            ),
            Action::PatchReopen => (Kind::Reopened, None),
            _ => continue,
        };

        entries.push((
            event.clock,
            Entry {
                kind,
                revision,
                author,
                timestamp,
                id: oid_hex,
            },
        ));
    }

    // Stable, so events sharing a clock — every event on a pre-clock patch —
    // keep the DAG's own order rather than being shuffled by the sort.
    entries.sort_by_key(|(clock, _)| *clock);
    let mut entries: Vec<Entry> = entries.into_iter().map(|(_, e)| e).collect();

    // Anchor thread comments to the revision that was current where they sit in
    // the sequence — which is the revision their author was in fact looking at.
    //
    // By position, not by timestamp: timestamps come from whichever machine
    // wrote the event, so comparing two authors' clocks decides nothing, and a
    // skewed clock would file a comment under the wrong revision.
    //
    // Thread comments only. A review that recorded no revision stays
    // unanchored: attributing one from DAG position is exactly what issue
    // `e5096ffc` removed and issue `05b18df6` did not bring back — a comment
    // mislabelled by a revision is a display detail, whereas a review is a vote
    // and its revision decides which other vote it supersedes.
    let mut current = 1;
    for entry in &mut entries {
        match (&entry.kind, entry.revision) {
            (Kind::Revision { .. }, Some(n)) => current = n,
            (Kind::Comment { .. }, None) => entry.revision = Some(current),
            _ => {}
        }
    }

    Ok((patch, entries))
}

/// Whether `action` carries a body a correction could name.
fn carries_a_body(action: &Action) -> bool {
    matches!(
        action,
        Action::PatchCreate { .. }
            | Action::PatchRevision { .. }
            | Action::PatchRevise { .. }
            | Action::PatchComment { .. }
            | Action::PatchInlineComment { .. }
            | Action::PatchReview { .. }
    )
}

fn authorized(
    owners: &std::collections::HashMap<String, String>,
    target: &str,
    author: &Author,
) -> bool {
    owners
        .get(target)
        .is_some_and(|owner| *owner == author.email)
}

/// Render the timeline as one line per event.
///
/// One line each, because the value of the view is the shape of the sequence;
/// a full body per entry would bury it. `patch show` prints bodies in full.
pub fn to_writer(entries: &[Entry], writer: &mut dyn std::io::Write) -> Result<(), Error> {
    if entries.is_empty() {
        writeln!(writer, "No events recorded.")?;
        return Ok(());
    }

    for entry in entries {
        let label = entry.kind.label(entry.revision);
        // The anchor. `patch log` already opens a revision line with `rN`, so
        // repeating it as `@rN` there would be noise; everything else needs it
        // to say which revision it is about.
        let anchor = match (&entry.kind, entry.revision) {
            (Kind::Revision { .. }, _) | (_, None) => String::new(),
            (_, Some(n)) => format!("@r{}  ", n),
        };

        // Built as parts and joined, so no arm has to reason about whether the
        // one before it left a separator behind.
        let mut parts: Vec<String> = Vec::new();
        match &entry.kind {
            Kind::Revision {
                commit,
                body,
                edited,
            } => {
                parts.push(if commit.is_empty() {
                    state::UNKNOWN_COMMIT.to_string()
                } else {
                    commit.chars().take(8).collect()
                });
                if entry.revision == Some(1) {
                    parts.push("(initial)".to_string());
                }
                parts.extend(body_part(body.as_deref(), *edited, false));
            }
            Kind::Comment {
                body,
                edited,
                deleted,
            } => parts.extend(body_part(Some(body), *edited, *deleted)),
            Kind::InlineComment {
                file,
                line,
                body,
                edited,
                deleted,
            } => {
                parts.push(format!("{}:{}", file, line));
                parts.extend(body_part(Some(body), *edited, *deleted));
            }
            Kind::Review {
                verdict,
                body,
                edited,
            } => {
                parts.push(verdict.clone());
                parts.extend(body_part(Some(body), *edited, false));
            }
            Kind::Label { label } => parts.push(format!("+{}", label)),
            Kind::Unlabel { label } => parts.push(format!("-{}", label)),
            Kind::Edited { target }
            | Kind::Deleted { target }
            | Kind::Resolved { target }
            | Kind::CommentUnresolved { target } => parts.push(format!("{:.8}", target)),
            Kind::Closed { reason } => parts.extend(reason.clone()),
            Kind::Merged { commit } => parts.extend(commit.as_deref().map(|c| format!("{:.8}", c))),
            Kind::Reopened => {}
        }

        writeln!(
            writer,
            "{}  {:<8}  {}  {}{}",
            entry.timestamp,
            label,
            entry.author.name,
            anchor,
            parts.join("  ")
        )?;
    }
    Ok(())
}

/// A body reduced to one line, with `(edited)` following the words it changed,
/// or the tombstone in place of a deleted one. `None` when there is nothing to
/// show — an empty body contributes no trailing whitespace.
fn body_part(body: Option<&str>, edited: bool, deleted: bool) -> Option<String> {
    if deleted {
        return Some(crate::TOMBSTONE.to_string());
    }
    let marker = if edited { " (edited)" } else { "" };
    match body {
        Some(b) if !b.trim().is_empty() => {
            Some(format!("{}{}", crate::patch::summarize_body(b), marker))
        }
        // A body edited down to nothing still happened; say so rather than
        // rendering an entry that looks like it never had one.
        _ if edited => Some(marker.trim().to_string()),
        _ => None,
    }
}

pub fn to_json(entries: &[Entry]) -> Result<String, Error> {
    Ok(serde_json::to_string_pretty(entries)?)
}