onetaskgraph_plugin_api/update.rs
1//! A targeted update of one existing task: every field a caller names, and nothing else.
2//!
3//! The narrow writes each change one field in one call, and a copy rewrites the whole item. A
4//! caller whose change touches a status and several metadata keys at once needs neither: this
5//! names exactly the fields that changed, and a source applies them in as few writes as it
6//! can, sending nothing for a field that already holds the requested value.
7//!
8//! The source's answer carries everything the engine reports about the call — the task read
9//! back, the fields it wrote, and the `delivers` it held before — so the engine reads nothing
10//! of its own around it. That is the difference between one read of an item and two on a
11//! hosted backend, on every update.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::{
20 DependencyEdge, Direction, ItemWrite, MetadataKey, NativeId, PageRequest, Priority, Repository,
21 SourceError, Status, Task, TaskRef, TaskSource,
22};
23
24/// A targeted update of one existing task. A member left `None` (or empty) leaves that field
25/// exactly as the source holds it.
26///
27/// Labels and project membership are deliberately outside it — an existing item is never
28/// moved, and no caller writes labels this way — and so is creation, which is a copy's. Its
29/// `repositories` list is a field of the item like its title, and is replaced whole.
30#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
31pub struct TaskUpdate {
32 /// The task's title.
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub title: Option<String>,
35 /// `Task::content` exactly as a read reports it; a metadata block the source keeps in the
36 /// same backend field is kept byte for byte.
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub content: Option<String>,
39 /// Name and category. A source that keeps status names stores the name; one that maps by
40 /// category (GitHub Projects) writes its mapped option, exactly as `write_task` does, and
41 /// refuses a category it has disabled in the same words.
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub status: Option<Status>,
44 /// The task's priority; `none` clears it.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub priority: Option<Priority>,
47 // llmlint: ignore-block[invalid_states_unrepresentable] `metadata_set` and `metadata_remove` are two members because Contract 1 of the `graphql-writeback-quota` project record fixes them by name and type, and onepipeline builds against exactly this shape; the one state they can express that is not an update — one key in both — is refused by `consistent` before any source is read or written, in one wording every source and the engine share. A single map of key to set-or-remove would be the contract's owner's change to make, not this crate's.
48 /// Keys added or replaced; every other key is kept.
49 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
50 #[schemars(!skip_serializing_if)]
51 pub metadata_set: BTreeMap<MetadataKey, Value>,
52 /// Keys removed; a key the task does not hold is no write. Refused when it names a key
53 /// `metadata_set` also names.
54 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
55 #[schemars(!skip_serializing_if)]
56 pub metadata_remove: BTreeSet<MetadataKey>,
57 // llmlint: ignore-end[invalid_states_unrepresentable]
58 /// Replaces the list; the engine keeps each ticket's `delivered_by` in step.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub delivers: Option<Vec<TaskRef>>,
61 /// Replaces the task's forward dependency edges (`from` is this task); the source sends
62 /// only the difference.
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub depends_on: Option<Vec<DependencyEdge>>,
65 /// Replaces the task's [`Task::repositories`] whole; an empty list clears it. Where the
66 /// item's issue lives is never moved by it: it is the list the item names, as a read
67 /// reports it.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub repositories: Option<Vec<Repository>>,
70 /// Replaces every person the task is assigned to with exactly these — empty unassigns.
71 /// The engine names at most one, and only to a source declaring
72 /// [`Capabilities::assignees`](crate::Capabilities::assignees). A source refuses a person
73 /// it cannot assign, naming them, rather than writing the rest without them.
74 // llmlint: ignore[invalid_states_unrepresentable] The same list `Task::assignees` reads, so `applied_to` and `changed` compare one shape and a source checks its write's answer against what it asked for; the one-person rule is the engine's, refused before any source is asked.
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub assignees: Option<Vec<String>>,
77}
78
79impl TaskUpdate {
80 /// Whether this update names no field at all.
81 #[must_use]
82 pub fn is_empty(&self) -> bool {
83 self.title.is_none()
84 && self.content.is_none()
85 && self.status.is_none()
86 && self.priority.is_none()
87 && self.metadata_set.is_empty()
88 && self.metadata_remove.is_empty()
89 && self.delivers.is_none()
90 && self.depends_on.is_none()
91 && self.repositories.is_none()
92 && self.assignees.is_none()
93 }
94
95 /// Whether this update names `field`, which is the only way a source may report it written.
96 #[must_use]
97 pub fn names(&self, field: UpdatedField) -> bool {
98 match field {
99 UpdatedField::Title => self.title.is_some(),
100 UpdatedField::Content => self.content.is_some(),
101 UpdatedField::Status => self.status.is_some(),
102 UpdatedField::Priority => self.priority.is_some(),
103 UpdatedField::Metadata => {
104 !self.metadata_set.is_empty() || !self.metadata_remove.is_empty()
105 }
106 UpdatedField::Delivers => self.delivers.is_some(),
107 UpdatedField::DependsOn => self.depends_on.is_some(),
108 UpdatedField::Repositories => self.repositories.is_some(),
109 UpdatedField::Assignees => self.assignees.is_some(),
110 }
111 }
112
113 /// Every metadata key this update both sets and removes, in order.
114 ///
115 /// An update naming one is refused before anything is written: which of the two it meant
116 /// is not something a source may guess.
117 #[must_use]
118 pub fn contradictions(&self) -> Vec<&MetadataKey> {
119 self.metadata_remove
120 .iter()
121 .filter(|key| self.metadata_set.contains_key(*key))
122 .collect()
123 }
124
125 /// The refusal of an update that both sets and removes a key, or `Ok` when it does not.
126 ///
127 /// Spelled once here, so every source and the engine refuse it in the same words.
128 ///
129 /// # Errors
130 ///
131 /// Returns [`SourceError::Refused`] naming every such key.
132 pub fn consistent(&self) -> Result<(), SourceError> {
133 let both = self.contradictions();
134 if both.is_empty() {
135 return Ok(());
136 }
137 let named: Vec<&str> = both.iter().map(|key| key.as_str()).collect();
138 Err(SourceError::Refused {
139 message: format!(
140 "this update both sets and removes the metadata {} {}; next: name each key \
141 either to set or to remove, not both",
142 if named.len() == 1 { "key" } else { "keys" },
143 named.join(", ")
144 ),
145 })
146 }
147
148 /// `task` as it reads once this update is applied to it: every named field replaced, every
149 /// named metadata key set or removed, and everything else exactly as it was.
150 ///
151 /// [`depends_on`](Self::depends_on) is not a member of a [`Task`], so it is not applied
152 /// here.
153 #[must_use]
154 pub fn applied_to(&self, task: &Task) -> Task {
155 let mut updated = task.clone();
156 if let Some(title) = &self.title {
157 updated.title.clone_from(title);
158 }
159 if let Some(content) = &self.content {
160 updated.content = Some(content.clone());
161 }
162 if let Some(status) = &self.status {
163 updated.status = status.clone();
164 }
165 if let Some(priority) = self.priority {
166 updated.priority = priority;
167 }
168 for (key, value) in &self.metadata_set {
169 updated
170 .metadata
171 .insert(key.as_str().to_owned(), value.clone());
172 }
173 for key in &self.metadata_remove {
174 updated.metadata.remove(key.as_str());
175 }
176 if let Some(delivers) = &self.delivers {
177 updated.delivers.clone_from(delivers);
178 }
179 if let Some(repositories) = &self.repositories {
180 updated.repositories.clone_from(repositories);
181 }
182 if let Some(assignees) = &self.assignees {
183 updated.assignees = Some(assignees.clone());
184 }
185 updated
186 }
187
188 /// The fields this update names whose value differs between `before` and `after` — which,
189 /// for a source that compares before it writes, is exactly what it wrote.
190 ///
191 /// [`UpdatedField::DependsOn`] is never among them: a [`Task`] carries no edges, so a
192 /// source says whether it wrote those itself.
193 #[must_use]
194 pub fn changed(&self, before: &Task, after: &Task) -> BTreeSet<UpdatedField> {
195 let mut written = BTreeSet::new();
196 if self.title.is_some() && before.title != after.title {
197 written.insert(UpdatedField::Title);
198 }
199 if self.content.is_some() && before.content != after.content {
200 written.insert(UpdatedField::Content);
201 }
202 if self.status.is_some() && before.status != after.status {
203 written.insert(UpdatedField::Status);
204 }
205 if self.priority.is_some() && before.priority != after.priority {
206 written.insert(UpdatedField::Priority);
207 }
208 let named = self
209 .metadata_set
210 .keys()
211 .chain(&self.metadata_remove)
212 .map(MetadataKey::as_str);
213 if named
214 .into_iter()
215 .any(|key| before.metadata.get(key) != after.metadata.get(key))
216 {
217 written.insert(UpdatedField::Metadata);
218 }
219 if self.delivers.is_some() && before.delivers != after.delivers {
220 written.insert(UpdatedField::Delivers);
221 }
222 if self.repositories.is_some() && before.repositories != after.repositories {
223 written.insert(UpdatedField::Repositories);
224 }
225 if self.assignees.is_some()
226 && !crate::work::same_assignees(
227 before.assignees.as_deref().unwrap_or_default(),
228 after.assignees.as_deref().unwrap_or_default(),
229 )
230 {
231 written.insert(UpdatedField::Assignees);
232 }
233 written
234 }
235
236 /// Apply this update to the task `id` of `source` by rewriting it: read it, apply the
237 /// update, and — only when anything differs — [`write_task`](TaskSource::write_task) it
238 /// with its `target` set, then read it back. `None` when `source` holds no such task.
239 ///
240 /// This is what [`TaskSource::update_task`] does for a source that does not override it,
241 /// and it is public so a host forwarding the call to a peer that does not declare the
242 /// targeted update can fall back to exactly it. It is correct and not minimal: a write it
243 /// makes rewrites the whole item, and a source that can send less owes an override.
244 ///
245 /// The forward edges are read whenever they are needed — to compare against a named
246 /// `depends_on`, or to carry unchanged through a rewrite a named `depends_on` does not
247 /// replace — because a [`write_task`](TaskSource::write_task) replaces them.
248 ///
249 /// # Errors
250 ///
251 /// Returns [`SourceError::Refused`] for an update that both sets and removes a key,
252 /// [`SourceError::Malformed`] when the write answers an id other than `id` or the task could
253 /// not be read back after it, and
254 /// whatever the source's own read or write fails with.
255 pub async fn rewrite<S: TaskSource + ?Sized>(
256 &self,
257 source: &S,
258 id: &NativeId,
259 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
260 self.consistent()?;
261 let Some(held) = source.get_task(id).await? else {
262 return Ok(None);
263 };
264 let updated = self.applied_to(&held);
265 let (depends_on, edges_differ) = match &self.depends_on {
266 Some(wanted) => {
267 let current = forward_edges(source, id).await?;
268 (wanted.clone(), !same_edges(¤t, wanted))
269 }
270 None if updated != held => (forward_edges(source, id).await?, false),
271 None => (Vec::new(), false),
272 };
273 if updated == held && !edges_differ {
274 return Ok(Some(TaskUpdateOutcome {
275 delivers_before: held.delivers.clone(),
276 task: held,
277 written: BTreeSet::new(),
278 }));
279 }
280 let wrote = source
281 .write_task(&ItemWrite {
282 target: Some(id.clone()),
283 item: updated,
284 depends_on,
285 })
286 .await?;
287 // A write that names its target answers that target: another id means the source
288 // wrote some other record, and reading `id` back would report an update that never
289 // reached it.
290 if &wrote != id {
291 return Err(SourceError::Malformed {
292 message: format!(
293 "task {id} was updated, and the source answered that it wrote {wrote}"
294 ),
295 });
296 }
297 let task = source
298 .get_task(id)
299 .await?
300 .ok_or_else(|| SourceError::Malformed {
301 message: format!("task {id} was updated and then could not be read back"),
302 })?;
303 let mut written = self.changed(&held, &task);
304 if edges_differ {
305 written.insert(UpdatedField::DependsOn);
306 }
307 Ok(Some(TaskUpdateOutcome {
308 task,
309 written,
310 delivers_before: held.delivers,
311 }))
312 }
313}
314
315/// One field of a task a [`TaskUpdate`] names, as the answer to it reports what was written.
316///
317/// kebab-case on the wire. [`Metadata`](Self::Metadata) stands for every metadata key the
318/// update set or removed together; [`DependsOn`](Self::DependsOn) for the task's forward edges.
319#[derive(
320 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
321)]
322#[serde(rename_all = "kebab-case")]
323pub enum UpdatedField {
324 /// [`TaskUpdate::title`].
325 Title,
326 /// [`TaskUpdate::content`].
327 Content,
328 /// [`TaskUpdate::status`].
329 Status,
330 /// [`TaskUpdate::priority`].
331 Priority,
332 /// [`TaskUpdate::metadata_set`] and [`TaskUpdate::metadata_remove`].
333 Metadata,
334 /// [`TaskUpdate::delivers`].
335 Delivers,
336 /// [`TaskUpdate::depends_on`].
337 DependsOn,
338 /// [`TaskUpdate::repositories`].
339 Repositories,
340 /// [`TaskUpdate::assignees`].
341 Assignees,
342}
343
344/// What a source answers a [`TaskUpdate`] of a task it holds with.
345///
346/// Everything the engine reports about the call is read off this, so the engine sends no read
347/// of its own before or after it: `task` is what it answers with, `written` is what it says
348/// was written, and `delivers_before` is what tells it which delivered tasks the update
349/// dropped.
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
351pub struct TaskUpdateOutcome {
352 /// The task as this source reads it once the update landed.
353 pub task: Task,
354 /// The fields this source actually wrote — empty when nothing differed, and never a field
355 /// the update did not name. Required on the wire: an answer that leaves it out has not said
356 /// what it wrote, which is not the same as having written nothing.
357 pub written: BTreeSet<UpdatedField>,
358 /// The task's [`Task::delivers`] as this source held it before the update, which is the
359 /// list a named `delivers` replaced. Required on the wire, for the reason `written` is: an
360 /// answer leaving it out would hide every delivered task the update dropped.
361 pub delivers_before: Vec<TaskRef>,
362}
363
364/// Every forward dependency edge `source` reports at `id`, walked to exhaustion.
365async fn forward_edges<S: TaskSource + ?Sized>(
366 source: &S,
367 id: &NativeId,
368) -> Result<Vec<DependencyEdge>, SourceError> {
369 let limit = source.capabilities().max_page_size.max(1);
370 let mut edges = Vec::new();
371 let mut cursor = None;
372 loop {
373 let page = source
374 .task_dependencies(id, Direction::DependsOn, &PageRequest { cursor, limit })
375 .await?;
376 edges.extend(page.items);
377 match page.next {
378 Some(next) => cursor = Some(next),
379 None => return Ok(edges),
380 }
381 }
382}
383
384/// Whether two edge lists name the same far ends by the same kinds, whatever their order and
385/// whatever each says its near end is.
386fn same_edges(held: &[DependencyEdge], wanted: &[DependencyEdge]) -> bool {
387 let far = |edges: &[DependencyEdge]| -> BTreeSet<(String, String, String)> {
388 edges
389 .iter()
390 .map(|edge| {
391 (
392 edge.to.id().to_owned(),
393 format!("{:?}", edge.to.kind),
394 format!("{:?}", edge.kind),
395 )
396 })
397 .collect()
398 };
399 held.len() == wanted.len() && far(held) == far(wanted)
400}