1use std::io::Write;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use serde::Serialize;
16
17#[derive(Debug, Clone, PartialEq, Serialize)]
19pub struct ProjectOption {
20 pub id: String,
21 pub name: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct ProjectValue {
27 pub field: String,
29 pub kind: String,
31 pub value: String,
34 #[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 ProjectBoard {
45 board: String,
46 number: i64,
47 title: String,
48 owner: String,
49 url: String,
50 closed: bool,
51 },
52 ProjectField {
54 board: String,
55 field: String,
56 name: String,
57 kind: String,
58 options: Vec<ProjectOption>,
59 },
60 ProjectItem {
64 board: String,
65 item: String,
66 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 body: String,
80 fields: Vec<ProjectValue>,
81 },
82 ProjectItemRemoved { board: String, item: String },
84 BoardReference { project: String, board: String },
86 Issue {
88 repo: String,
89 number: i64,
90 title: String,
91 state: String,
92 url: String,
93 updated_at: String,
94 },
95 SyncScope {
97 scope: String,
98 outcome: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 error: Option<String>,
102 },
103 SyncProblem { problem: String },
105 SyncSummary {
107 changed: usize,
108 unchanged: usize,
109 failed: usize,
110 no_token: bool,
111 problems: usize,
112 },
113 Status {
115 mirror: String,
116 token: Option<String>,
117 scopes: usize,
118 failing: usize,
119 never_synced: usize,
120 },
121 Message { level: String, text: String },
123 Failure { message: String },
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Stream {
130 Out,
131 Err,
132}
133
134type Host = Box<dyn Fn(Stream, &str) + Send + Sync>;
136
137#[derive(Serialize)]
138struct Line<'a> {
139 seq: u64,
140 ts: String,
141 #[serde(flatten)]
142 record: &'a Record,
143}
144
145pub struct Output {
147 jsonl: bool,
148 seq: AtomicU64,
149 host: Option<Host>,
152}
153
154impl Output {
155 pub fn new(jsonl: bool) -> Self {
156 Self {
157 jsonl,
158 seq: AtomicU64::new(0),
159 host: None,
160 }
161 }
162
163 pub fn through(host: impl Fn(Stream, &str) + Send + Sync + 'static) -> Self {
169 Self {
170 jsonl: false,
171 seq: AtomicU64::new(0),
172 host: Some(Box::new(host)),
173 }
174 }
175
176 fn write(&self, stream: Stream, line: &str) {
177 match (&self.host, stream) {
178 (Some(host), _) => host(stream, line),
179 (None, Stream::Out) => {
180 let mut stdout = std::io::stdout().lock();
181 let _ = writeln!(stdout, "{line}");
182 }
183 (None, Stream::Err) => eprintln!("{line}"),
184 }
185 }
186
187 pub fn is_jsonl(&self) -> bool {
188 self.jsonl
189 }
190
191 pub fn emit(&self, record: Record) {
194 if !self.jsonl {
195 return;
196 }
197 self.line(&record);
198 }
199
200 fn line(&self, record: &Record) {
201 let line = Line {
202 seq: self.seq.fetch_add(1, Ordering::SeqCst) + 1,
203 ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
204 record,
205 };
206 let text = serde_json::to_string(&line).expect("a record always serialises");
207 let mut stdout = std::io::stdout().lock();
208 let _ = writeln!(stdout, "{text}");
209 let _ = stdout.flush();
210 }
211
212 pub fn say(&self, lines: impl IntoIterator<Item = String>) {
214 if self.jsonl {
215 return;
216 }
217 for line in lines {
218 self.write(Stream::Out, &line);
219 }
220 }
221
222 pub fn explain(&self, text: &str) {
225 if self.jsonl {
226 return;
227 }
228 for line in text.lines() {
229 self.write(Stream::Err, line);
230 }
231 }
232
233 pub fn warn(&self, text: &str) {
235 if self.jsonl {
236 self.line(&Record::Message {
237 level: "warning".to_string(),
238 text: text.to_string(),
239 });
240 } else {
241 self.write(Stream::Err, &format!("wsb github: {text}"));
242 }
243 }
244
245 pub fn fail(&self, message: &str) {
247 if self.jsonl {
248 self.line(&Record::Failure {
249 message: message.to_string(),
250 });
251 } else {
252 self.write(Stream::Err, &format!("wsb github: {message}"));
253 }
254 }
255}