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