Skip to main content

wsb_github/
output.rs

1//! What the command line says, for a person or for a program.
2//!
3//! A person reads a board as columns of titles; a program needs the ids the
4//! next call takes. Both are served by the same run: every board, field, card
5//! and sync outcome is a record, and the prose a terminal shows is written
6//! only when the run is not `--jsonl` — the records already say all of it.
7//!
8//! Every JSONL line carries the same envelope: a monotonic `seq`, an RFC 3339
9//! UTC timestamp `ts`, and the `type` that says which record it is. A gap in
10//! `seq` means a line was lost.
11
12use std::io::Write;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use serde::Serialize;
16
17/// One option of a single-select board field.
18#[derive(Debug, Clone, PartialEq, Serialize)]
19pub struct ProjectOption {
20    pub id: String,
21    pub name: String,
22}
23
24/// One field value on one card.
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct ProjectValue {
27    /// The field's name.
28    pub field: String,
29    /// `single_select` | `text` | `number` | `date` | `iteration`.
30    pub kind: String,
31    /// The value as a person reads it: an option's name, the text, the number,
32    /// the date as YYYY-MM-DD, an iteration's title.
33    pub value: String,
34    /// The option's id, for a single-select.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub option: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum Record {
42    /// One Projects board. `board` is its node id, which every later call
43    /// may name it by; `number` is what a person calls it.
44    ProjectBoard {
45        board: String,
46        number: i64,
47        title: String,
48        owner: String,
49        url: String,
50        closed: bool,
51    },
52    /// One field of a board, with the options of a single-select.
53    ProjectField {
54        board: String,
55        field: String,
56        name: String,
57        kind: String,
58        options: Vec<ProjectOption>,
59    },
60    /// One card, as GitHub last returned it. `fields` is by field NAME, in
61    /// the board's field order, and carries only what the card has a value
62    /// for.
63    ProjectItem {
64        board: String,
65        item: String,
66        /// `ISSUE` | `PULL_REQUEST` | `DRAFT_ISSUE` | `REDACTED`.
67        kind: String,
68        title: String,
69        #[serde(skip_serializing_if = "Option::is_none")]
70        url: Option<String>,
71        #[serde(skip_serializing_if = "Option::is_none")]
72        repository: Option<String>,
73        #[serde(skip_serializing_if = "Option::is_none")]
74        number: Option<i64>,
75        #[serde(skip_serializing_if = "Option::is_none")]
76        state: Option<String>,
77        archived: bool,
78        /// The description, as markdown. Empty is a fact.
79        body: String,
80        fields: Vec<ProjectValue>,
81    },
82    /// A card was taken off a board.
83    ProjectItemRemoved { board: String, item: String },
84    /// A board one project references.
85    BoardReference { project: String, board: String },
86    /// One issue, as the mirror holds it.
87    Issue {
88        repo: String,
89        number: i64,
90        title: String,
91        state: String,
92        url: String,
93        updated_at: String,
94    },
95    /// What a sync did with one scope.
96    SyncScope {
97        scope: String,
98        /// `changed` | `unchanged` | `failed`.
99        outcome: String,
100        #[serde(skip_serializing_if = "Option::is_none")]
101        error: Option<String>,
102    },
103    /// Something about what to mirror that could not be found out.
104    SyncProblem { problem: String },
105    /// One whole sync.
106    SyncSummary {
107        changed: usize,
108        unchanged: usize,
109        failed: usize,
110        no_token: bool,
111        problems: usize,
112    },
113    /// Where the token and the mirror are.
114    Status {
115        mirror: String,
116        token: Option<String>,
117        scopes: usize,
118        failing: usize,
119        never_synced: usize,
120    },
121    /// Something said that is not data.
122    Message { level: String, text: String },
123    /// Why the command did not do what was asked.
124    Failure { message: String },
125}
126
127#[derive(Serialize)]
128struct Line<'a> {
129    seq: u64,
130    ts: String,
131    #[serde(flatten)]
132    record: &'a Record,
133}
134
135/// One run's voice.
136pub struct Output {
137    jsonl: bool,
138    seq: AtomicU64,
139}
140
141impl Output {
142    pub fn new(jsonl: bool) -> Self {
143        Self {
144            jsonl,
145            seq: AtomicU64::new(0),
146        }
147    }
148
149    pub fn is_jsonl(&self) -> bool {
150        self.jsonl
151    }
152
153    /// A record: one line under `--jsonl`, nothing otherwise — the prose says
154    /// it for a person.
155    pub fn emit(&self, record: Record) {
156        if !self.jsonl {
157            return;
158        }
159        self.line(&record);
160    }
161
162    fn line(&self, record: &Record) {
163        let line = Line {
164            seq: self.seq.fetch_add(1, Ordering::SeqCst) + 1,
165            ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
166            record,
167        };
168        let text = serde_json::to_string(&line).expect("a record always serialises");
169        let mut stdout = std::io::stdout().lock();
170        let _ = writeln!(stdout, "{text}");
171        let _ = stdout.flush();
172    }
173
174    /// Prose for a person; nothing under `--jsonl`.
175    pub fn say(&self, lines: impl IntoIterator<Item = String>) {
176        if self.jsonl {
177            return;
178        }
179        let mut stdout = std::io::stdout().lock();
180        for line in lines {
181            let _ = writeln!(stdout, "{line}");
182        }
183    }
184
185    /// A warning: the run goes on, and somebody should know.
186    pub fn warn(&self, text: &str) {
187        if self.jsonl {
188            self.line(&Record::Message {
189                level: "warning".to_string(),
190                text: text.to_string(),
191            });
192        } else {
193            eprintln!("wsb github: {text}");
194        }
195    }
196
197    /// Why the run stopped.
198    pub fn fail(&self, message: &str) {
199        if self.jsonl {
200            self.line(&Record::Failure {
201                message: message.to_string(),
202            });
203        } else {
204            eprintln!("wsb github: {message}");
205        }
206    }
207}