ytcli/api/models.rs
1//! Normalised entities.
2//!
3//! These are *our* schema, not Tracker's. `--json` emits these so that scripts
4//! keep working across Tracker API changes; `--json-raw` exists for the cases
5//! where someone genuinely needs the upstream payload. Every unmapped field is
6//! preserved in `extra`, which is also where a queue's custom fields arrive.
7
8use serde::{Deserialize, Serialize};
9
10/// A person, reduced to what output ever shows.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct User {
13 pub id: String,
14 #[serde(default)]
15 pub login: Option<String>,
16 #[serde(default)]
17 pub display: Option<String>,
18}
19
20/// One entry of an organisation-wide dictionary: an issue type, a priority, a
21/// status or a resolution.
22///
23/// All four endpoints answer with the same shape, so one type covers them.
24///
25/// `key` and `name` are not interchangeable and the difference is the reason
26/// this is worth listing at all: `name` comes back in the organisation's own
27/// language — a Russian organisation answers `Ошибка` — while `key` is the
28/// stable English handle a write has to use.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DictEntry {
31 pub key: String,
32 pub name: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub description: Option<String>,
35 /// Where Tracker sorts it. Priorities and resolutions have one; issue types
36 /// do not.
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub order: Option<i64>,
39 /// A status's category — `new`, `inProgress`, `paused`, `done`, `cancelled`.
40 /// Only statuses carry it, and it is what makes a status list readable
41 /// without knowing the workflow.
42 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
43 pub category: Option<String>,
44}
45
46/// A person, in full.
47///
48/// Distinct from [`User`] on purpose: that one is a *reference* to somebody,
49/// the shape a login arrives in on an issue, and it is deliberately small
50/// because it appears in every answer. This one is the directory record, and
51/// only the user commands return it.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Person {
54 pub login: String,
55 pub uid: String,
56 pub display: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub email: Option<String>,
59 /// Left the organisation. Still assignable in old issues, which is why the
60 /// listing says so rather than hiding them.
61 pub dismissed: bool,
62 /// Somebody outside the organisation with access to it.
63 pub external: bool,
64}
65
66/// How two issues relate. Rendered on every issue view, because "what blocks
67/// this" is the question an agent asks right after "what is this".
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "kebab-case")]
70pub enum LinkKind {
71 Blocks,
72 IsBlockedBy,
73 Parent,
74 Subtask,
75 Duplicates,
76 IsDuplicatedBy,
77 Depends,
78 IsDependentBy,
79 Relates,
80 Epic,
81 HasEpic,
82 Other,
83}
84
85impl LinkKind {
86 /// Short label used in compact output.
87 #[must_use]
88 pub fn label(self) -> &'static str {
89 match self {
90 Self::Blocks => "blocks",
91 Self::IsBlockedBy => "is blocked by",
92 Self::Parent => "parent",
93 Self::Subtask => "subtask",
94 Self::Duplicates => "duplicates",
95 Self::IsDuplicatedBy => "is duplicated by",
96 Self::Depends => "depends on",
97 Self::IsDependentBy => "is depended on by",
98 Self::Relates => "relates",
99 Self::Epic => "epic",
100 Self::HasEpic => "has epic",
101 Self::Other => "related to",
102 }
103 }
104}
105
106/// One edge from an issue to another issue.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Link {
109 /// What `issue link delete` takes. Carried because the help names this
110 /// listing as where the id comes from, and for a while it did not.
111 pub id: String,
112 pub kind: LinkKind,
113 /// Tracker's own wording for the relationship, in whatever language it
114 /// answered in. Shown when `kind` is [`LinkKind::Other`], so an unrecognised
115 /// relation still says what it is instead of reading as "link".
116 #[serde(default)]
117 pub relation: Option<String>,
118 pub key: String,
119 #[serde(default)]
120 pub summary: Option<String>,
121 #[serde(default)]
122 pub status: Option<String>,
123}
124
125/// One edge from an issue to something outside Tracker.
126///
127/// Distinct from [`Link`], which joins two issues in the same organisation.
128/// These point at wiki pages, repositories, other trackers — whatever the
129/// organisation connected — and until now their absence from the output was
130/// indistinguishable from there being none.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct RemoteLink {
133 pub id: String,
134 /// Tracker's own wording for our side of the relationship, in whatever
135 /// language it answered in.
136 #[serde(default)]
137 pub relation: Option<String>,
138 /// Which application holds the other end.
139 #[serde(default)]
140 pub application: Option<String>,
141 /// The other end's own key, in that application's numbering rather than
142 /// Tracker's.
143 #[serde(default)]
144 pub key: Option<String>,
145 #[serde(default)]
146 pub title: Option<String>,
147}
148
149/// An issue, normalised.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Issue {
152 pub key: String,
153 pub summary: String,
154 #[serde(default)]
155 pub status: Option<String>,
156 /// The status's stable handle — `open`, `inProgress`, `closed`.
157 ///
158 /// `status` comes back in the organisation's language, so it is the wrong
159 /// thing to compare against: a Russian organisation answers `Закрыт`, and
160 /// code that matched on `closed` matched nothing and said so in no way at
161 /// all. This is the half that is the same everywhere.
162 #[serde(default)]
163 pub status_key: Option<String>,
164 #[serde(default)]
165 pub issue_type: Option<String>,
166 #[serde(default)]
167 pub priority: Option<String>,
168 /// The priority's stable handle — `blocker`, `critical`, `normal`.
169 #[serde(default)]
170 pub priority_key: Option<String>,
171 #[serde(default)]
172 pub queue: Option<String>,
173 #[serde(default)]
174 pub assignee: Option<User>,
175 #[serde(default)]
176 pub author: Option<User>,
177 #[serde(default)]
178 pub created_at: Option<jiff::Timestamp>,
179 #[serde(default)]
180 pub updated_at: Option<jiff::Timestamp>,
181 #[serde(default)]
182 pub description: Option<String>,
183 #[serde(default)]
184 pub links: Vec<Link>,
185 #[serde(default)]
186 pub comment_count: Option<u32>,
187 /// Custom and unmapped fields, kept out of the compact view by default.
188 #[serde(default, flatten)]
189 pub extra: serde_json::Map<String, serde_json::Value>,
190}
191
192/// One page of results plus the totals needed to say "shown N of M".
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct Page<T> {
195 pub items: Vec<T>,
196 pub page: u32,
197 pub per_page: u32,
198 /// `None` when Tracker did not report a total for this query.
199 pub total: Option<u64>,
200}
201
202impl<T> Page<T> {
203 /// Whether more results exist beyond this page.
204 #[must_use]
205 pub fn has_more(&self) -> bool {
206 match self.total {
207 Some(total) => u64::from(self.page) * u64::from(self.per_page) < total,
208 None => u32::try_from(self.items.len()).is_ok_and(|len| len == self.per_page),
209 }
210 }
211}
212
213/// One comment, reduced to what output shows.
214///
215/// `text` is the single most attacker-influenced string this tool handles: it is
216/// free-form, written by anyone with access to the issue, and read by whatever
217/// called us. It is never rewritten, only fenced (`render::untrusted`).
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Comment {
220 pub id: String,
221 pub text: String,
222 #[serde(default)]
223 pub author: Option<User>,
224 #[serde(default)]
225 pub created_at: Option<jiff::Timestamp>,
226}
227
228/// One entry in an issue's worklog.
229///
230/// `duration` stays in Tracker's ISO 8601 form here — the API's word for it,
231/// which is what a `--format json` caller is scripting against.
232/// [`crate::api::duration::human`] is what turns it into something to read.
233#[derive(Debug, Clone, serde::Serialize)]
234pub struct Worklog {
235 pub id: String,
236 pub duration: String,
237 #[serde(default)]
238 pub author: Option<User>,
239 #[serde(default)]
240 pub start: Option<jiff::Timestamp>,
241 /// What the time was spent on, when whoever logged it said.
242 #[serde(default)]
243 pub comment: Option<String>,
244 /// Which issue the time went to.
245 ///
246 /// Absent when the entry was read through an issue, which already named it,
247 /// and present when it was found across the organisation, where it is the
248 /// only thing that says what the time was for.
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub issue: Option<String>,
251}
252
253/// One recorded change to an issue.
254///
255/// Deliberately flattened from what Tracker sends: one event can touch several
256/// fields, and the useful unit to read is a field that changed, not the
257/// transaction it changed in. The event id is kept on each so the two can be
258/// put back together.
259#[derive(Debug, Clone, serde::Serialize)]
260pub struct Change {
261 pub id: String,
262 #[serde(default)]
263 pub at: Option<jiff::Timestamp>,
264 #[serde(default)]
265 pub by: Option<User>,
266 /// `IssueCreated`, `IssueUpdated`, `IssueWorkflow` and the rest. Tracker's
267 /// own word, not one of ours.
268 pub kind: String,
269 pub fields: Vec<FieldChange>,
270}
271
272/// One field, before and after.
273///
274/// Both sides are `None` where Tracker sent null: on a creation there is no
275/// before, and clearing a field leaves no after. Printing `null` would be a
276/// third thing to interpret.
277#[derive(Debug, Clone, serde::Serialize)]
278pub struct FieldChange {
279 pub field: String,
280 #[serde(default)]
281 pub from: Option<String>,
282 #[serde(default)]
283 pub to: Option<String>,
284}
285
286/// One line of an issue's checklist.
287#[derive(Debug, Clone, serde::Serialize)]
288pub struct ChecklistItem {
289 pub id: String,
290 pub text: String,
291 pub checked: bool,
292 /// A checklist item can carry an assignee and a deadline of its own.
293 #[serde(default)]
294 pub assignee: Option<User>,
295 #[serde(default)]
296 pub deadline: Option<String>,
297}
298
299/// A project, portfolio or goal.
300///
301/// These live behind one endpoint family and differ only in which fields are
302/// populated, so they share a model rather than three near-identical ones. Note
303/// `short_id`: it is what an issue's `project` field refers to, and it is not
304/// the `id` the entity endpoints take — confusing the two is the obvious
305/// mistake, so both are carried.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct Entity {
308 pub id: String,
309 pub short_id: Option<i64>,
310 pub entity_type: Option<String>,
311 pub summary: String,
312 #[serde(default)]
313 pub status: Option<String>,
314 #[serde(default)]
315 pub lead: Option<User>,
316 #[serde(default)]
317 pub start: Option<String>,
318 #[serde(default)]
319 pub end: Option<String>,
320 #[serde(default)]
321 pub description: Option<String>,
322 /// The portfolio this sits in, when it sits in one.
323 #[serde(default)]
324 pub parent: Option<String>,
325 /// Tracker's optimistic-concurrency counter. A write that quotes a stale
326 /// one is refused rather than applied over somebody else's change.
327 #[serde(default)]
328 pub version: Option<u64>,
329}
330
331/// One attachment of an issue.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct Attachment {
334 pub id: String,
335 /// The filename as uploaded. Chosen by whoever uploaded it, so it is never
336 /// used to decide a path on disk without sanitising.
337 pub name: String,
338 pub size: Option<u64>,
339 pub mimetype: Option<String>,
340 #[serde(default)]
341 pub author: Option<User>,
342 #[serde(default)]
343 pub created_at: Option<jiff::Timestamp>,
344 /// Where the bytes are. Checked against the configured API host before it
345 /// is followed.
346 pub content: Option<String>,
347}