Skip to main content

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}