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}