wsb-github 0.255.6379

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
//! What the command line says, for a person or for a program.
//!
//! A person reads a board as columns of titles; a program needs the ids the
//! next call takes. Both are served by the same run: every board, field, card
//! and sync outcome is a record, and the prose a terminal shows is written
//! only when the run is not `--jsonl` — the records already say all of it.
//!
//! Every JSONL line carries the same envelope: a monotonic `seq`, an RFC 3339
//! UTC timestamp `ts`, and the `type` that says which record it is. A gap in
//! `seq` means a line was lost.

use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};

use serde::Serialize;

/// One option of a single-select board field.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProjectOption {
    pub id: String,
    pub name: String,
}

/// One field value on one card.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProjectValue {
    /// The field's name.
    pub field: String,
    /// `single_select` | `text` | `number` | `date` | `iteration`.
    pub kind: String,
    /// The value as a person reads it: an option's name, the text, the number,
    /// the date as YYYY-MM-DD, an iteration's title.
    pub value: String,
    /// The option's id, for a single-select.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub option: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Record {
    /// One Projects board. `board` is its node id, which every later call
    /// may name it by; `number` is what a person calls it.
    ProjectBoard {
        board: String,
        number: i64,
        title: String,
        owner: String,
        url: String,
        closed: bool,
    },
    /// One field of a board, with the options of a single-select.
    ProjectField {
        board: String,
        field: String,
        name: String,
        kind: String,
        options: Vec<ProjectOption>,
    },
    /// One card, as GitHub last returned it. `fields` is by field NAME, in
    /// the board's field order, and carries only what the card has a value
    /// for.
    ProjectItem {
        board: String,
        item: String,
        /// `ISSUE` | `PULL_REQUEST` | `DRAFT_ISSUE` | `REDACTED`.
        kind: String,
        title: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        url: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        repository: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        number: Option<i64>,
        #[serde(skip_serializing_if = "Option::is_none")]
        state: Option<String>,
        archived: bool,
        /// The description, as markdown. Empty is a fact.
        body: String,
        fields: Vec<ProjectValue>,
    },
    /// A card was taken off a board.
    ProjectItemRemoved { board: String, item: String },
    /// A board one project references.
    BoardReference { project: String, board: String },
    /// One issue, as the mirror holds it.
    Issue {
        repo: String,
        number: i64,
        title: String,
        state: String,
        url: String,
        updated_at: String,
    },
    /// What a sync did with one scope.
    SyncScope {
        scope: String,
        /// `changed` | `unchanged` | `failed`.
        outcome: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        error: Option<String>,
    },
    /// Something about what to mirror that could not be found out.
    SyncProblem { problem: String },
    /// One whole sync.
    SyncSummary {
        changed: usize,
        unchanged: usize,
        failed: usize,
        no_token: bool,
        problems: usize,
    },
    /// Where the token and the mirror are.
    Status {
        mirror: String,
        token: Option<String>,
        scopes: usize,
        failing: usize,
        never_synced: usize,
    },
    /// Something said that is not data.
    Message { level: String, text: String },
    /// Why the command did not do what was asked.
    Failure { message: String },
}

/// Which of the terminal's two streams a line of prose belongs on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stream {
    Out,
    Err,
}

/// Where prose goes when it is not written to the terminal.
type Host = Box<dyn Fn(Stream, &str) + Send + Sync>;

#[derive(Serialize)]
struct Line<'a> {
    seq: u64,
    ts: String,
    #[serde(flatten)]
    record: &'a Record,
}

/// One run's voice.
pub struct Output {
    jsonl: bool,
    seq: AtomicU64,
    /// Set when another command line hosts this one and packages what it says
    /// into its own stream; the terminal otherwise.
    host: Option<Host>,
}

impl Output {
    pub fn new(jsonl: bool) -> Self {
        Self {
            jsonl,
            seq: AtomicU64::new(0),
            host: None,
        }
    }

    /// Prose handed to `host`, line by line, instead of written to the
    /// terminal.
    ///
    /// For `wsb github`: wsb owns its stream, and under its `--jsonl` a line
    /// written past it would be the one line in the run that is not a record.
    pub fn through(host: impl Fn(Stream, &str) + Send + Sync + 'static) -> Self {
        Self {
            jsonl: false,
            seq: AtomicU64::new(0),
            host: Some(Box::new(host)),
        }
    }

    fn write(&self, stream: Stream, line: &str) {
        match (&self.host, stream) {
            (Some(host), _) => host(stream, line),
            (None, Stream::Out) => {
                let mut stdout = std::io::stdout().lock();
                let _ = writeln!(stdout, "{line}");
            }
            (None, Stream::Err) => eprintln!("{line}"),
        }
    }

    pub fn is_jsonl(&self) -> bool {
        self.jsonl
    }

    /// A record: one line under `--jsonl`, nothing otherwise — the prose says
    /// it for a person.
    pub fn emit(&self, record: Record) {
        if !self.jsonl {
            return;
        }
        self.line(&record);
    }

    fn line(&self, record: &Record) {
        let line = Line {
            seq: self.seq.fetch_add(1, Ordering::SeqCst) + 1,
            ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
            record,
        };
        let text = serde_json::to_string(&line).expect("a record always serialises");
        let mut stdout = std::io::stdout().lock();
        let _ = writeln!(stdout, "{text}");
        let _ = stdout.flush();
    }

    /// Prose for a person; nothing under `--jsonl`.
    pub fn say(&self, lines: impl IntoIterator<Item = String>) {
        if self.jsonl {
            return;
        }
        for line in lines {
            self.write(Stream::Out, &line);
        }
    }

    /// Prose that goes with a refusal, on the error stream; nothing under
    /// `--jsonl`, where the failure record already says it.
    pub fn explain(&self, text: &str) {
        if self.jsonl {
            return;
        }
        for line in text.lines() {
            self.write(Stream::Err, line);
        }
    }

    /// A warning: the run goes on, and somebody should know.
    pub fn warn(&self, text: &str) {
        if self.jsonl {
            self.line(&Record::Message {
                level: "warning".to_string(),
                text: text.to_string(),
            });
        } else {
            self.write(Stream::Err, &format!("wsb github: {text}"));
        }
    }

    /// Why the run stopped.
    pub fn fail(&self, message: &str) {
        if self.jsonl {
            self.line(&Record::Failure {
                message: message.to_string(),
            });
        } else {
            self.write(Stream::Err, &format!("wsb github: {message}"));
        }
    }
}