use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use clap::{Args, ValueEnum};
use pointbreak::documents::history_document;
use pointbreak::model::RevisionId;
use pointbreak::session::event::EventType;
use pointbreak::session::{
EventVerificationPolicy, HistoryCursor, LivenessToken, RefFilterMode, ReviewHistoryOptions,
read_events_for_display, review_history,
};
use crate::cli::output;
#[derive(Debug, Args)]
pub(super) struct HistoryArgs {
#[arg(long, default_value = ".")]
repo: PathBuf,
#[arg(long)]
revision: Option<String>,
#[arg(long)]
track: Option<String>,
#[arg(long = "event-type")]
event_types: Vec<HistoryEventTypeArg>,
#[arg(long = "ref", alias = "branch")]
ref_name: Option<String>,
#[arg(long, value_enum, default_value = "label")]
by: HistoryRefByArg,
#[arg(long)]
include_body: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
cursor: Option<String>,
#[arg(long)]
watch: bool,
#[arg(long, default_value_t = 3000)]
poll_ms: u64,
#[command(flatten)]
format_args: output::FormatArgs,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum HistoryEventTypeArg {
ReviewInitialized,
RevisionCaptured,
ReviewObservationRecorded,
ReviewAssessmentRecorded,
ValidationCheckRecorded,
InputRequestOpened,
InputRequestResponded,
RevisionRefAssociated,
RevisionRefWithdrawn,
RevisionCommitAssociated,
RevisionCommitWithdrawn,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
#[value(rename_all = "kebab-case")]
enum HistoryRefByArg {
#[default]
Label,
Liveness,
}
impl From<HistoryRefByArg> for RefFilterMode {
fn from(by: HistoryRefByArg) -> Self {
match by {
HistoryRefByArg::Label => RefFilterMode::Label,
HistoryRefByArg::Liveness => RefFilterMode::Liveness,
}
}
}
pub(super) fn run(
args: HistoryArgs,
stdout: &mut dyn Write,
) -> Result<(), Box<dyn std::error::Error>> {
let span = tracing::info_span!("shore.review.history");
let _entered = span.enter();
tracing::debug!(command = "review.history", "command_start");
if args.watch {
return watch(&args, stdout);
}
render_once(&args, stdout)
}
fn render_once(
args: &HistoryArgs,
stdout: &mut dyn Write,
) -> Result<(), Box<dyn std::error::Error>> {
let result = review_history(history_options(args)?);
let document = history_document(result?);
let format = output::resolve_format(args.format_args.explicit(), output::OutputFormat::Json)?;
output::write_document_json_fallback(stdout, format, &document)
}
fn watch(args: &HistoryArgs, stdout: &mut dyn Write) -> Result<(), Box<dyn std::error::Error>> {
let interval = Duration::from_millis(args.poll_ms);
let mut last_seen: Option<(String, usize)> = None;
loop {
let fingerprint = watch_fingerprint(&args.repo)?;
if last_seen.as_ref() != Some(&fingerprint) {
render_once(args, stdout)?;
stdout.flush()?;
last_seen = Some(fingerprint);
}
std::thread::sleep(interval);
}
}
fn watch_fingerprint(
repo: &std::path::Path,
) -> Result<(String, usize), Box<dyn std::error::Error>> {
let (events, diagnostics) = read_events_for_display(repo)?;
let token = LivenessToken::for_journal(&events)?;
Ok((token.event_set_hash, diagnostics.len()))
}
fn history_options(args: &HistoryArgs) -> Result<ReviewHistoryOptions, Box<dyn std::error::Error>> {
let mut options = ReviewHistoryOptions::new(&args.repo)
.with_include_body(args.include_body)
.with_read_for_display(true);
if let Some(limit) = args.limit {
options = options.with_limit(limit);
}
if let Some(token) = &args.cursor {
let cursor = HistoryCursor::decode(token)
.map_err(|_| "invalid --cursor: pass an opaque nextCursor from a prior response")?;
options = options.with_cursor(cursor);
}
if let Some(revision) = &args.revision {
let ids = crate::cli::id_resolver::IdResolver::new(&args.repo);
options = options.with_revision_id(RevisionId::new(ids.rev(revision)?));
}
if let Some(track) = &args.track {
options = options.with_track(track.clone());
}
for event_type in args.event_types.iter().copied() {
options = options.with_event_type(event_type.into());
}
if let Some(ref_name) = &args.ref_name {
options = options.with_ref_filter(ref_name.clone(), args.by.into());
}
if let Some(map) = crate::cli::common::discover_delegation_map(&args.repo) {
options = options.with_delegation_map(map);
}
options = options.with_trust_set(crate::cli::common::discover_trust_set(&args.repo));
options = options.with_verification_policy(EventVerificationPolicy::advisory());
options =
options.with_actor_attributes(crate::cli::common::discover_actor_attributes(&args.repo));
Ok(options)
}
impl From<HistoryEventTypeArg> for EventType {
fn from(value: HistoryEventTypeArg) -> Self {
match value {
HistoryEventTypeArg::ReviewInitialized => Self::ReviewInitialized,
HistoryEventTypeArg::RevisionCaptured => Self::WorkObjectProposed,
HistoryEventTypeArg::ReviewObservationRecorded => Self::ReviewObservationRecorded,
HistoryEventTypeArg::ReviewAssessmentRecorded => Self::ReviewAssessmentRecorded,
HistoryEventTypeArg::ValidationCheckRecorded => Self::ValidationCheckRecorded,
HistoryEventTypeArg::InputRequestOpened => Self::InputRequestOpened,
HistoryEventTypeArg::InputRequestResponded => Self::InputRequestResponded,
HistoryEventTypeArg::RevisionRefAssociated => Self::RevisionRefAssociated,
HistoryEventTypeArg::RevisionRefWithdrawn => Self::RevisionRefWithdrawn,
HistoryEventTypeArg::RevisionCommitAssociated => Self::RevisionCommitAssociated,
HistoryEventTypeArg::RevisionCommitWithdrawn => Self::RevisionCommitWithdrawn,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn watch_fingerprint_changes_when_only_a_retired_event_appears() {
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
let before = watch_fingerprint(repo.path()).unwrap();
let events_dir = pointbreak::session::store_dir_for_repo(repo.path())
.unwrap()
.join("events");
std::fs::create_dir_all(&events_dir).unwrap();
std::fs::write(
events_dir.join(format!("{}.json", "a".repeat(64))),
br#"{"eventType":"review_disposition_recorded"}"#,
)
.unwrap();
let after = watch_fingerprint(repo.path()).unwrap();
assert_ne!(
before, after,
"a new retired event must move the watch fingerprint"
);
assert_eq!(
after.1,
before.1 + 1,
"the skip-diagnostic count increments"
);
}
}