Skip to main content

onetaskgraph_plugin_api/
issue.rs

1//! An issue a landing closed, named the way the version-control side names it, and what one
2//! source holds for it — and the items whose card contradicts their closure.
3//!
4//! Both halves serve a source whose status is a card on a board beside an issue that closes on
5//! its own: [`Capabilities::complete_by_issue`](crate::Capabilities::complete_by_issue) and
6//! [`Capabilities::reconcile_status`](crate::Capabilities::reconcile_status) say whether a
7//! source answers them, and a source whose status *is* its closure declares neither.
8
9use std::fmt;
10use std::hash::{Hash, Hasher};
11use std::str::FromStr;
12
13use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
14use serde::{Deserialize, Serialize};
15
16use crate::{
17    Classification, ItemKind, NativeId, Project, Repository, StatusCategory, Task, TaskRef,
18};
19
20/// The pattern every [`IssueRef`] is spelled in: `owner/name#N`, the number at most nineteen
21/// digits — every such number fits the `u64` it is read into.
22///
23/// What the JSON Schema publishes; [`IssueRef`]'s `FromStr` is the same rule written out, and
24/// `tests/issue_ref.rs` derives a matcher from this constant and holds the two to one language.
25pub const ISSUE_REF_PATTERN: &str = "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+#[1-9][0-9]{0,18}$";
26
27/// The most digits an issue number is spelled with: nineteen, so every one fits a `u64`.
28const MAX_NUMBER_DIGITS: usize = 19;
29
30/// One issue of one repository, as `owner/name#N` — exactly the spelling `onevcs` reports in a
31/// landing event's `closes`.
32///
33/// The owner and the name are compared ignoring case, as GitHub resolves them; the number is
34/// the issue's own number on that repository.
35#[derive(Debug, Clone, Eq)]
36pub struct IssueRef {
37    /// The login of the repository's owner.
38    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] The fields are the contract's stated shape, `IssueRef { owner, name, number }`, which the engine and the read API build against; `FromStr` is the one validating boundary every spelling crosses, and serde reads through it.
39    /// The repository's name.
40    pub name: String, // llmlint: ignore[invalid_states_unrepresentable] As `owner`: the contract's stated shape, validated by `FromStr`.
41    /// The issue's number on that repository.
42    pub number: u64, // llmlint: ignore[invalid_states_unrepresentable] As `owner`: the contract's stated shape, validated by `FromStr`.
43}
44
45impl IssueRef {
46    /// Whether `owner` is a GitHub login: letters, digits and hyphens.
47    fn valid_owner(owner: &str) -> bool {
48        !owner.is_empty()
49            && owner
50                .chars()
51                .all(|character| character.is_ascii_alphanumeric() || character == '-')
52    }
53
54    /// Whether `name` is a repository name: letters, digits, `.`, `_` and `-`.
55    fn valid_name(name: &str) -> bool {
56        !name.is_empty()
57            && name.chars().all(|character| {
58                character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
59            })
60    }
61}
62
63impl PartialEq for IssueRef {
64    fn eq(&self, other: &Self) -> bool {
65        self.number == other.number
66            && self.owner.eq_ignore_ascii_case(&other.owner)
67            && self.name.eq_ignore_ascii_case(&other.name)
68    }
69}
70
71impl Hash for IssueRef {
72    fn hash<H: Hasher>(&self, state: &mut H) {
73        self.owner.to_ascii_lowercase().hash(state);
74        self.name.to_ascii_lowercase().hash(state);
75        self.number.hash(state);
76    }
77}
78
79impl FromStr for IssueRef {
80    type Err = String;
81
82    /// Read `owner/name#N`, refusing anything else and naming the reference refused.
83    fn from_str(value: &str) -> Result<Self, Self::Err> {
84        let refused = || {
85            format!(
86                "{value:?} is not an issue reference; spell one owner/name#N, as a landing's \
87                 `closes` does"
88            )
89        };
90        let (repository, number) = value.rsplit_once('#').ok_or_else(refused)?;
91        let (owner, name) = repository.split_once('/').ok_or_else(refused)?;
92        if !Self::valid_owner(owner)
93            || !Self::valid_name(name)
94            || number.is_empty()
95            || number.len() > MAX_NUMBER_DIGITS
96            || number.starts_with('0')
97            || !number.chars().all(|digit| digit.is_ascii_digit())
98        {
99            return Err(refused());
100        }
101        Ok(Self {
102            owner: owner.to_owned(),
103            name: name.to_owned(),
104            number: number.parse().map_err(|_| refused())?,
105        })
106    }
107}
108
109impl fmt::Display for IssueRef {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "{}/{}#{}", self.owner, self.name, self.number)
112    }
113}
114
115impl Serialize for IssueRef {
116    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
117        serializer.collect_str(self)
118    }
119}
120
121impl<'de> Deserialize<'de> for IssueRef {
122    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
123        String::deserialize(deserializer)?
124            .parse()
125            .map_err(serde::de::Error::custom)
126    }
127}
128
129impl JsonSchema for IssueRef {
130    fn schema_name() -> std::borrow::Cow<'static, str> {
131        "IssueRef".into()
132    }
133
134    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
135        json_schema!({
136            "type": "string",
137            "pattern": ISSUE_REF_PATTERN,
138            "description": "One issue of one repository, as `owner/name#N`.",
139        })
140    }
141}
142
143/// What one source holds for one issue a landing closed, and what moving it to `done` would
144/// write — the answer to [`TaskSource::issue_holdings`](crate::TaskSource::issue_holdings).
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
146pub struct IssueHolding {
147    /// The issue asked about.
148    pub issue: IssueRef,
149    /// What completing the issue comes to here, its tag and members beside the issue.
150    #[serde(flatten)]
151    pub completion: Completion,
152}
153
154/// The item one source holds for an issue — a task, or a project such as a plan's home card —
155/// as far as moving it to `done` needs it.
156///
157/// It carries of the item exactly what the write and the rules around it need — which item and
158/// of which kind, the card it sits at, the tasks it delivers, and what decides where its id may
159/// be written — rather than the whole item, because the read that answers it is priced per issue
160/// and a whole item is not what a landing's closure needs.
161// llmlint: ignore[names_match_behavior] The contract this change lands names the struct `HeldTask` — the engine and the read API that build against the release name it so, and its wire member is `task` — and widening what it holds to a project is a change to that contract's breadth that only its owner may answer with a rename; its documentation and `kind` say what it holds.
162// llmlint: ignore[invalid_states_unrepresentable] The wire form must stay one flat object in which an absent `kind` reads as a task, so a plugin built against 0.8.0 is read unchanged; a tagged split of `delivers` into a task variant would make that absent tag unreadable. The engine's one reader, `answered_each` in `onetaskgraph-core`'s `engine/closure.rs`, refuses as malformed a held project that names a delivered task, so the combination never reaches a write.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
164pub struct HeldTask {
165    /// The item, by this source's own id for it.
166    pub id: NativeId,
167    /// Which kind of item it is. Defaulted to `task` and left out of the wire while it is one,
168    /// so a plugin written before a project could be held answers exactly what it answered.
169    #[serde(default = "task_kind", skip_serializing_if = "is_task")]
170    #[schemars(!skip_serializing_if)]
171    pub kind: ItemKind,
172    /// The name of the option the task's card sits at, as the source spells it, when it sits
173    /// at one.
174    #[serde(default)]
175    pub option: Option<String>,
176    /// The task's own classification.
177    #[serde(default)]
178    pub classification: Classification,
179    /// The repositories the task names.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub repositories: Vec<Repository>,
182    /// The tasks it delivers, which a write of it to `done` keeps in step — always empty for a
183    /// project, which delivers none.
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub delivers: Vec<TaskRef>,
186}
187
188/// The kind a [`HeldTask`] that does not say is: a task, as every holding was before a project
189/// could be held.
190const fn task_kind() -> ItemKind {
191    ItemKind::Task
192}
193
194/// Whether `kind` is the one a [`HeldTask`] leaves unsaid.
195// The signature is the one `skip_serializing_if` calls with.
196#[allow(clippy::trivially_copy_pass_by_ref)]
197fn is_task(kind: &ItemKind) -> bool {
198    *kind == ItemKind::Task
199}
200
201/// What moving one issue's item to `done` comes to at one source — the task it holds riding on
202/// every outcome that has one, so none can be told without its task, or with one, that has not.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
204#[serde(tag = "completion", rename_all = "kebab-case")]
205pub enum Completion {
206    /// The source holds no item for the issue, or cannot see the issue at all.
207    Absent,
208    /// The issue's closure already says something else, which a landing does not overrule.
209    Skipped {
210        /// The task held for the issue.
211        task: HeldTask,
212        /// Why, as `closed as <reason>`.
213        reason: String,
214    },
215    /// The issue is closed as completed and its card already sits at the `done` option.
216    Unchanged {
217        /// The task held for the issue.
218        task: HeldTask,
219    },
220    /// The item is to be written `done`: its card moved to the `done` option, and its issue
221    /// closed as completed if it is still open.
222    Due {
223        /// The task held for the issue.
224        task: HeldTask,
225        /// The name of the option `done` writes it at.
226        to: String,
227    },
228}
229
230/// The two closures a card is brought in line with: the categories a closed issue's reason can
231/// read as that name an option to move its card to.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "kebab-case")]
234pub enum ClosedAs {
235    /// Closed as completed, or with no reason: `done`.
236    Done,
237    /// Closed as not planned or as a duplicate: `cancelled`.
238    Cancelled,
239}
240
241impl ClosedAs {
242    /// The status category this closure reads as.
243    #[must_use]
244    pub const fn category(self) -> StatusCategory {
245        match self {
246            Self::Done => StatusCategory::Done,
247            Self::Cancelled => StatusCategory::Cancelled,
248        }
249    }
250}
251
252/// One item whose card contradicts its issue's closure, and the option that brings it in line
253/// — one entry of [`TaskSource::status_contradictions`](crate::TaskSource::status_contradictions).
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
255pub struct StatusContradiction {
256    /// The item as it reads now: `task` or `project`, named by its kind.
257    #[serde(flatten)]
258    pub item: ContradictedItem,
259    /// The option its card sits at, or `None` for a card with no option.
260    #[serde(default)]
261    pub option: Option<String>,
262    /// What its closure reads as.
263    pub closed_as: ClosedAs,
264    /// The name of the option the source's mapping gives that category for the item's kind,
265    /// which its card is moved to.
266    pub to: String,
267}
268
269/// The item a [`StatusContradiction`] is about, under the member naming its kind — `task`, as
270/// every contradiction was before a project could be one, or `project`.
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
272#[serde(rename_all = "kebab-case")]
273pub enum ContradictedItem {
274    /// A task, as `get_task` reads it.
275    Task(Task),
276    /// A project, as `get_project` reads it.
277    Project(Project),
278}
279
280impl ContradictedItem {
281    /// Which kind of item it is.
282    #[must_use]
283    pub const fn kind(&self) -> ItemKind {
284        match self {
285            Self::Task(_) => ItemKind::Task,
286            Self::Project(_) => ItemKind::Project,
287        }
288    }
289
290    /// The item's own id at its source.
291    #[must_use]
292    pub const fn id(&self) -> &NativeId {
293        match self {
294            Self::Task(task) => &task.id,
295            Self::Project(project) => &project.id,
296        }
297    }
298
299    /// The item's declared classification.
300    #[must_use]
301    pub const fn classification(&self) -> Classification {
302        match self {
303            Self::Task(task) => task.classification,
304            Self::Project(project) => project.classification,
305        }
306    }
307
308    /// The repositories the item names.
309    #[must_use]
310    pub fn repositories(&self) -> &[Repository] {
311        match self {
312            Self::Task(task) => &task.repositories,
313            Self::Project(project) => &project.repositories,
314        }
315    }
316}