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}