onetaskgraph_plugin_api/work.rs
1//! The work items every source is normalised into.
2
3use chrono::{DateTime, Utc};
4use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8
9use crate::{NativeId, SourceName};
10
11/// One unit of work as a source reports it.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
13pub struct Task {
14 /// The source's own opaque identifier.
15 pub id: NativeId,
16 /// The one-line summary a user recognises the task by.
17 pub title: String,
18 /// The long-form body, when the source has one.
19 pub content: Option<String>,
20 /// The source's status, normalised and preserved.
21 pub status: Status,
22 /// Inline rather than by id: a source returning a task already knows them.
23 pub labels: Vec<Label>,
24 /// `None` is a first-class case — an orphan task — not an edge case.
25 pub project: Option<NativeId>,
26 /// Where a human can open this task.
27 // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs), and in AGENTS.md's "The plugin contract": this crate's field types ARE the approved contract, six undispatched nodes compile against `Option<String>` here, and only the contract's owner may narrow one. No code change is available that clears this without editing that frozen surface.
28 // llmlint: ignore[boundary_inputs_validated] parsing this into a URL type would narrow the same frozen surface, and would add a URL dependency to the crate AGENTS.md says to keep still ("Keep the api crate still" — every change here re-tests every plugin). A plugin that returns a string this interface cannot represent is what `SourceError::Malformed` is for. Contract owner's call; recorded in AGENTS.md, "The plugin contract".
29 pub url: Option<String>,
30 /// When the source says the task was created.
31 pub created_at: Option<DateTime<Utc>>,
32 /// When the source says the task last changed.
33 pub updated_at: Option<DateTime<Utc>>,
34 /// Caller-defined attributes, preserving their JSON types.
35 ///
36 /// Keys are free-form, with two reserved prefixes: `onetaskgraph.` belongs to this
37 /// product — [`Repository::METADATA_KEY`] and [`DependencyEdge::RECORDED_KEY`] are
38 /// the two every source honours, and [`ItemKind::METADATA_KEY`] is one plugin's —
39 /// and `onepipeline.` belongs to that consumer. Every other key is the caller's, and
40 /// a source returns it exactly as it holds it.
41 #[serde(default)]
42 pub metadata: BTreeMap<String, Value>,
43 /// Normalized repository origins this task concerns, in source order and without
44 /// repeats.
45 #[serde(default, deserialize_with = "unique_repositories")]
46 pub repositories: Vec<Repository>,
47}
48
49/// A grouping of tasks, shaped like a [`Task`] without a parent of its own.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51pub struct Project {
52 /// The source's own opaque identifier.
53 pub id: NativeId,
54 /// The one-line summary a user recognises the project by.
55 pub title: String,
56 /// The long-form body, when the source has one.
57 pub content: Option<String>,
58 /// The source's status, normalised and preserved.
59 pub status: Status,
60 /// Inline rather than by id, for the same reason as on [`Task`].
61 pub labels: Vec<Label>,
62 /// Where a human can open this project.
63 // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs), and in AGENTS.md's "The plugin contract": this crate's field types ARE the approved contract, six undispatched nodes compile against `Option<String>` here, and only the contract's owner may narrow one. No code change is available that clears this without editing that frozen surface.
64 // llmlint: ignore[boundary_inputs_validated] parsing this into a URL type would narrow the same frozen surface, and would add a URL dependency to the crate AGENTS.md says to keep still ("Keep the api crate still" — every change here re-tests every plugin). A plugin that returns a string this interface cannot represent is what `SourceError::Malformed` is for. Contract owner's call; recorded in AGENTS.md, "The plugin contract".
65 pub url: Option<String>,
66 /// When the source says the project was created.
67 pub created_at: Option<DateTime<Utc>>,
68 /// When the source says the project last changed.
69 pub updated_at: Option<DateTime<Utc>>,
70 /// Caller-defined attributes, preserving their JSON types, on the same terms as
71 /// [`Task::metadata`].
72 #[serde(default)]
73 pub metadata: BTreeMap<String, Value>,
74 /// Normalized repository origins this project concerns, in source order and without
75 /// repeats.
76 #[serde(default, deserialize_with = "unique_repositories")]
77 pub repositories: Vec<Repository>,
78}
79
80/// A repository identified by its normalized origin, without a URL scheme or `.git` suffix.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
82#[serde(try_from = "String", into = "String")]
83pub struct Repository(String);
84
85impl Repository {
86 /// The reserved metadata key a source reads these origins from when its backend has
87 /// no notion of its own.
88 ///
89 /// The key is spelled once, here, because every plugin has to agree on it: a source
90 /// that invented its own spelling would hold work nothing else could read.
91 pub const METADATA_KEY: &'static str = "onetaskgraph.repositories";
92
93 /// The normalized `host/owner/name` origin.
94 #[must_use]
95 pub fn as_str(&self) -> &str {
96 &self.0
97 }
98
99 /// The origins a source records under [`Self::METADATA_KEY`], or none.
100 ///
101 /// # Errors
102 ///
103 /// Returns a message when the key holds something other than a duplicate-free list
104 /// of normalized origins.
105 pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
106 let Some(value) = metadata.get(Self::METADATA_KEY) else {
107 return Ok(Vec::new());
108 };
109 let origins: Vec<Self> = serde_json::from_value(value.clone()).map_err(|error| {
110 format!(
111 "{} is not a list of repository origins: {error}",
112 Self::METADATA_KEY
113 )
114 })?;
115 Self::unique(origins)
116 }
117
118 /// The same origins, in the order given, once it is established none repeats.
119 ///
120 /// # Errors
121 ///
122 /// Returns a message naming the first origin that appears twice.
123 pub fn unique(origins: Vec<Self>) -> Result<Vec<Self>, String> {
124 let mut seen = std::collections::BTreeSet::new();
125 for origin in &origins {
126 if !seen.insert(origin.as_str()) {
127 return Err(format!(
128 "{:?} is listed twice; a repository list names each origin once",
129 origin.as_str()
130 ));
131 }
132 }
133 Ok(origins)
134 }
135}
136
137fn unique_repositories<'de, D>(deserializer: D) -> Result<Vec<Repository>, D::Error>
138where
139 D: serde::Deserializer<'de>,
140{
141 Repository::unique(Vec::<Repository>::deserialize(deserializer)?)
142 .map_err(serde::de::Error::custom)
143}
144
145impl TryFrom<String> for Repository {
146 type Error = String;
147
148 fn try_from(origin: String) -> Result<Self, Self::Error> {
149 let valid = !origin.is_empty()
150 && !origin.contains("://")
151 && !origin.ends_with(".git")
152 && !origin.chars().any(char::is_whitespace)
153 && origin.split('/').count() >= 3
154 && origin
155 .split('/')
156 .all(|part| !part.is_empty() && part != "." && part != "..");
157 valid.then_some(Self(origin.clone())).ok_or_else(|| format!(
158 "{origin:?} is not a normalized repository origin; use host/owner/name without a scheme or .git suffix"
159 ))
160 }
161}
162
163impl From<Repository> for String {
164 fn from(repository: Repository) -> Self {
165 repository.0
166 }
167}
168
169/// A tag a source attaches to work.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
171pub struct Label {
172 /// The source's own opaque identifier.
173 pub id: NativeId,
174 /// What a user filtering across sources actually types.
175 pub name: String,
176 /// The source's own colour for the label, when it has one.
177 pub color: Option<String>,
178}
179
180/// A source's status, kept in both normalised and original form.
181///
182/// `category` is what every filter compares against; `name` is the source's own
183/// wording, preserved so display never flattens "In Review" into "In Progress".
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
185pub struct Status {
186 /// The normalised value filters compare against.
187 pub category: StatusCategory,
188 /// The source's own label for this status.
189 pub name: String,
190}
191
192/// The normalised status vocabulary shared across every source.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
194#[serde(rename_all = "kebab-case")]
195pub enum StatusCategory {
196 /// Written down but not yet committed to as work.
197 Draft,
198 /// Known about, not yet queued.
199 Backlog,
200 /// Queued, not yet started.
201 Todo,
202 /// Being worked on.
203 InProgress,
204 /// Finished.
205 Done,
206 /// Abandoned.
207 Cancelled,
208 /// The source reported a status this vocabulary cannot place.
209 Unknown,
210}
211
212/// A dependency between two work items.
213///
214/// An endpoint may name another source. Keeping that far id on the near item is work data
215/// owned by its plugin, not an engine-side index or mirror; the engine reports it without
216/// resolving or fetching the far item.
217///
218/// A source uses its backend's own relationship wherever that relationship can name the
219/// far end, so the backend knows the graph and its own interface draws it. Where it
220/// cannot — a far end in another source, which no backend relates — the source reads
221/// [`Self::recorded`] from the near item instead. Only the forward direction is ever
222/// recorded; the reverse of a recorded edge is derived, exactly as a
223/// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source's reverse is.
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
225pub struct DependencyEdge {
226 /// The item the edge starts at, and the one that **depends on** the other.
227 ///
228 /// This is the orientation every source reports in, whichever way its own backend
229 /// spells the relationship: a GitHub `blockedBy` connection read for `ENG-1` yields
230 /// `from: ENG-1`, because `ENG-1` is what depends.
231 pub from: DependencyEndpoint,
232 /// The item the edge points at, and the one that must finish first.
233 pub to: DependencyEndpoint,
234 /// What the edge means.
235 pub kind: DependencyKind,
236}
237
238impl DependencyEdge {
239 /// The reserved metadata key a near item records a far end under.
240 ///
241 /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a plugin that
242 /// invented its own spelling would record a plan nothing else could read.
243 pub const RECORDED_KEY: &'static str = "onetaskgraph.depends_on";
244
245 /// The forward edges `near` records under [`Self::RECORDED_KEY`], or none.
246 ///
247 /// The key holds a list of endpoints — a bare string is a native id naming a task,
248 /// and `{"id": "<source>:<native>", "kind": "project"}` names any item of any source.
249 /// Each becomes one `blocks` edge from `near` to that endpoint.
250 ///
251 /// `natively_names` is the kind of item the near item's **own backend** can relate it
252 /// to — `Some(ItemKind::Task)` for a GitHub issue, whose `blockedBy` connection holds
253 /// issues; `None` for a GitHub draft, which has no such connection at all. An endpoint
254 /// of that kind naming an item of `near_source` is refused, because it names an item
255 /// the backend itself could hold, and the rule this key exists to serve is the
256 /// backend's own relationship first. Naming one's own source is what an unqualified id
257 /// does implicitly and what `<near_source>:<native>` does in writing, so both are
258 /// refused: which of the two spellings a plan happened to use says nothing about where
259 /// the edge belongs.
260 ///
261 /// An endpoint qualified to a *different* source is never refused. That is the whole
262 /// case this key is for: no backend relates an id in a system it knows nothing about.
263 ///
264 /// # Errors
265 ///
266 /// Returns a message when the key holds anything other than a list of endpoints, or
267 /// holds one the near item's own backend was supposed to name.
268 pub fn recorded(
269 metadata: &BTreeMap<String, Value>,
270 near: &NativeId,
271 near_kind: ItemKind,
272 near_source: &SourceName,
273 natively_names: Option<ItemKind>,
274 ) -> Result<Vec<Self>, String> {
275 let Some(value) = metadata.get(Self::RECORDED_KEY) else {
276 return Ok(Vec::new());
277 };
278 let far: Vec<DependencyEndpoint> =
279 serde_json::from_value(value.clone()).map_err(|error| {
280 format!(
281 "{} is not a list of dependency endpoints: {error}",
282 Self::RECORDED_KEY
283 )
284 })?;
285 far.into_iter()
286 .map(|to| {
287 let names_this_source = to
288 .source()
289 .is_none_or(|source| source == near_source.as_str());
290 if names_this_source && natively_names == Some(to.kind) {
291 return Err(format!(
292 "{key} on {near} records {to}, which this source can relate \
293 natively; record it as this backend's own dependency and keep \
294 {key} for a far end no relationship here can name",
295 key = Self::RECORDED_KEY
296 ));
297 }
298 Ok(Self {
299 from: DependencyEndpoint::from_native(near.clone(), near_kind),
300 to,
301 kind: DependencyKind::Blocks,
302 })
303 })
304 .collect()
305 }
306}
307
308/// One endpoint of a dependency edge.
309#[derive(Debug, Clone, PartialEq, Eq, Hash)]
310pub struct DependencyEndpoint {
311 /// A qualified `<source>:<native>` id, or a legacy native id which the engine
312 /// qualifies to the source reporting the edge.
313 id: EndpointIdentity,
314 /// Whether the endpoint names a task or a project.
315 pub kind: ItemKind,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Hash)]
319enum EndpointIdentity {
320 Native(String),
321 Qualified(String),
322}
323
324impl EndpointIdentity {
325 fn as_str(&self) -> &str {
326 match self {
327 Self::Native(id) | Self::Qualified(id) => id,
328 }
329 }
330
331 fn into_string(self) -> String {
332 match self {
333 Self::Native(id) | Self::Qualified(id) => id,
334 }
335 }
336}
337
338impl Serialize for DependencyEndpoint {
339 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
340 where
341 S: serde::Serializer,
342 {
343 #[derive(Serialize)]
344 struct Wire<'a> {
345 id: &'a str,
346 kind: ItemKind,
347 }
348 Wire {
349 id: self.id(),
350 kind: self.kind,
351 }
352 .serialize(serializer)
353 }
354}
355
356impl JsonSchema for DependencyEndpoint {
357 fn schema_name() -> std::borrow::Cow<'static, str> {
358 "DependencyEndpoint".into()
359 }
360
361 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
362 json_schema!({
363 "description": "A dependency endpoint. A bare string is a native id of the source reporting it, and this decoding reads one as a task; a reader that knows the level it was written at — a source's own configuration, say — may read it at that level instead.",
364 "oneOf": [
365 {"type": "string", "minLength": 1},
366 {
367 "type": "object",
368 "additionalProperties": false,
369 "required": ["id", "kind"],
370 "properties": {
371 "id": {"type": "string", "minLength": 1},
372 "kind": {"type": "string", "enum": ["task", "project"]}
373 }
374 }
375 ]
376 })
377 }
378}
379
380impl DependencyEndpoint {
381 /// Builds an endpoint from a serialized id, validating a qualified id when present.
382 ///
383 /// # Errors
384 ///
385 /// Returns an error for an empty id or a malformed `<source>:<native>` id.
386 pub fn new(id: String, kind: ItemKind) -> Result<Self, String> {
387 let is_qualified = id.contains(':');
388 let id = valid_endpoint_id(id)?;
389 Ok(Self {
390 id: if is_qualified {
391 EndpointIdentity::Qualified(id)
392 } else {
393 EndpointIdentity::Native(id)
394 },
395 kind,
396 })
397 }
398
399 /// Builds an endpoint from a source-native id, whose contents are deliberately opaque.
400 #[must_use]
401 pub fn from_native(id: NativeId, kind: ItemKind) -> Self {
402 Self {
403 id: EndpointIdentity::Native(id.0),
404 kind,
405 }
406 }
407
408 /// The serialized native or qualified id.
409 #[must_use]
410 pub fn id(&self) -> &str {
411 self.id.as_str()
412 }
413
414 /// Consumes the endpoint and returns its serialized id.
415 #[must_use]
416 pub fn into_id(self) -> String {
417 self.id.into_string()
418 }
419
420 /// Whether the id was explicitly supplied as a qualified endpoint.
421 #[must_use]
422 pub fn is_qualified(&self) -> bool {
423 matches!(self.id, EndpointIdentity::Qualified(_))
424 }
425
426 /// The source segment of a qualified id, or `None` for a native one.
427 ///
428 /// A native id belongs to whichever source reports it, so `None` reads as "this
429 /// source" rather than "no source".
430 #[must_use]
431 pub fn source(&self) -> Option<&str> {
432 match &self.id {
433 EndpointIdentity::Qualified(id) => id.split_once(':').map(|(source, _)| source),
434 EndpointIdentity::Native(_) => None,
435 }
436 }
437}
438
439impl<'de> Deserialize<'de> for DependencyEndpoint {
440 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
441 where
442 D: serde::Deserializer<'de>,
443 {
444 #[derive(Deserialize)]
445 #[serde(untagged)]
446 enum Wire {
447 Legacy(String),
448 Endpoint { id: String, kind: ItemKind },
449 }
450 match Wire::deserialize(deserializer)? {
451 Wire::Legacy(id) => {
452 if id.is_empty() {
453 return Err(serde::de::Error::custom(
454 "a dependency endpoint id cannot be empty",
455 ));
456 }
457 Ok(Self::from_native(NativeId(id), ItemKind::Task))
458 }
459 Wire::Endpoint { id, kind } => Self::new(id, kind).map_err(serde::de::Error::custom),
460 }
461 }
462}
463
464fn valid_endpoint_id(id: String) -> Result<String, String> {
465 if id.is_empty() {
466 return Err("a dependency endpoint id cannot be empty".into());
467 }
468 if let Some((source, native)) = id.split_once(':') {
469 crate::SourceName::new(source).map_err(|error| error.to_string())?;
470 if native.is_empty() {
471 return Err("a qualified dependency endpoint must name a native id".into());
472 }
473 }
474 Ok(id)
475}
476
477impl std::fmt::Display for DependencyEndpoint {
478 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 self.id().fmt(formatter)
480 }
481}
482
483impl PartialEq<NativeId> for DependencyEndpoint {
484 fn eq(&self, other: &NativeId) -> bool {
485 self.id() == other.0
486 }
487}
488
489/// The kind of work item named by a dependency endpoint.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
491#[serde(rename_all = "kebab-case")]
492pub enum ItemKind {
493 /// A task.
494 Task,
495 /// A project.
496 Project,
497}
498
499impl ItemKind {
500 /// The reserved metadata key an item is marked with when its backend cannot say
501 /// which kind it is.
502 ///
503 /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a key under
504 /// this product's prefix belongs to the product, and a plugin inventing its own
505 /// spelling would collide with the next one to want it.
506 ///
507 /// Unlike the other two reserved keys, this one obliges **no** source. A backend that
508 /// knows its own kinds — folders, native projects — never reads or writes it, and
509 /// passes it through as ordinary caller metadata with its JSON type intact, exactly
510 /// as it passes through every other key it does not own. `github-projects` is the one
511 /// source that needs it, because a GitHub Projects board holds only issues and an
512 /// empty project is indistinguishable from a task without it.
513 pub const METADATA_KEY: &'static str = "onetaskgraph.item_kind";
514
515 /// The value this kind is marked with under [`Self::METADATA_KEY`].
516 #[must_use]
517 pub const fn marker(self) -> &'static str {
518 match self {
519 Self::Task => "task",
520 Self::Project => "project",
521 }
522 }
523
524 /// The kind `metadata` marks, or `None` when it carries no marker at all.
525 ///
526 /// # Errors
527 ///
528 /// Returns a message when [`Self::METADATA_KEY`] holds anything other than the two
529 /// markers [`Self::marker`] spells.
530 pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
531 let Some(value) = metadata.get(Self::METADATA_KEY) else {
532 return Ok(None);
533 };
534 match value.as_str() {
535 Some(marker) if marker == Self::Task.marker() => Ok(Some(Self::Task)),
536 Some(marker) if marker == Self::Project.marker() => Ok(Some(Self::Project)),
537 _ => Err(format!(
538 "{} is {value}; it accepts only {:?} or {:?}",
539 Self::METADATA_KEY,
540 Self::Project.marker(),
541 Self::Task.marker()
542 )),
543 }
544 }
545}
546
547/// What a [`DependencyEdge`] means.
548///
549/// Both variants are read in the one direction [`DependencyEdge::from`] fixes: `from`
550/// depends on `to`. This enum said the opposite of that until the orientation was settled,
551/// which is why it is spelled out twice rather than once.
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
553#[serde(rename_all = "kebab-case")]
554pub enum DependencyKind {
555 /// `from` depends on `to`, and `to` must finish before `from` can.
556 // llmlint: ignore[names_match_behavior] `"blocks"` is the approved serialized value, spelled in docs/plugin-protocol.md §4.8 and both generated SDKs; the variant names the kind of dependency, and `from`/`to` carry the direction. Renaming it is a wire change and the contract owner's call.
557 Blocks,
558 /// `from` and `to` are linked without an ordering.
559 Related,
560}
561
562/// Which way a dependency query walks the graph.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
564#[serde(rename_all = "kebab-case")]
565pub enum Direction {
566 /// What this item depends on — the forward edges every source can report.
567 DependsOn,
568 /// What depends on this item — emulated by the engine for a
569 /// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source.
570 DependedOnBy,
571}