Skip to main content

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}