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::{Classification, NativeId, Repository, StatusCategory, Task, TaskRef};
17
18/// The pattern every [`IssueRef`] is spelled in: `owner/name#N`, the number at most nineteen
19/// digits — every such number fits the `u64` it is read into.
20///
21/// What the JSON Schema publishes; [`IssueRef`]'s `FromStr` is the same rule written out, and
22/// `tests/issue_ref.rs` derives a matcher from this constant and holds the two to one language.
23pub const ISSUE_REF_PATTERN: &str = "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+#[1-9][0-9]{0,18}$";
24
25/// The most digits an issue number is spelled with: nineteen, so every one fits a `u64`.
26const MAX_NUMBER_DIGITS: usize = 19;
27
28/// One issue of one repository, as `owner/name#N` — exactly the spelling `onevcs` reports in a
29/// landing event's `closes`.
30///
31/// The owner and the name are compared ignoring case, as GitHub resolves them; the number is
32/// the issue's own number on that repository.
33#[derive(Debug, Clone, Eq)]
34pub struct IssueRef {
35 /// The login of the repository's owner.
36 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.
37 /// The repository's name.
38 pub name: String, // llmlint: ignore[invalid_states_unrepresentable] As `owner`: the contract's stated shape, validated by `FromStr`.
39 /// The issue's number on that repository.
40 pub number: u64, // llmlint: ignore[invalid_states_unrepresentable] As `owner`: the contract's stated shape, validated by `FromStr`.
41}
42
43impl IssueRef {
44 /// Whether `owner` is a GitHub login: letters, digits and hyphens.
45 fn valid_owner(owner: &str) -> bool {
46 !owner.is_empty()
47 && owner
48 .chars()
49 .all(|character| character.is_ascii_alphanumeric() || character == '-')
50 }
51
52 /// Whether `name` is a repository name: letters, digits, `.`, `_` and `-`.
53 fn valid_name(name: &str) -> bool {
54 !name.is_empty()
55 && name.chars().all(|character| {
56 character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
57 })
58 }
59}
60
61impl PartialEq for IssueRef {
62 fn eq(&self, other: &Self) -> bool {
63 self.number == other.number
64 && self.owner.eq_ignore_ascii_case(&other.owner)
65 && self.name.eq_ignore_ascii_case(&other.name)
66 }
67}
68
69impl Hash for IssueRef {
70 fn hash<H: Hasher>(&self, state: &mut H) {
71 self.owner.to_ascii_lowercase().hash(state);
72 self.name.to_ascii_lowercase().hash(state);
73 self.number.hash(state);
74 }
75}
76
77impl FromStr for IssueRef {
78 type Err = String;
79
80 /// Read `owner/name#N`, refusing anything else and naming the reference refused.
81 fn from_str(value: &str) -> Result<Self, Self::Err> {
82 let refused = || {
83 format!(
84 "{value:?} is not an issue reference; spell one owner/name#N, as a landing's \
85 `closes` does"
86 )
87 };
88 let (repository, number) = value.rsplit_once('#').ok_or_else(refused)?;
89 let (owner, name) = repository.split_once('/').ok_or_else(refused)?;
90 if !Self::valid_owner(owner)
91 || !Self::valid_name(name)
92 || number.is_empty()
93 || number.len() > MAX_NUMBER_DIGITS
94 || number.starts_with('0')
95 || !number.chars().all(|digit| digit.is_ascii_digit())
96 {
97 return Err(refused());
98 }
99 Ok(Self {
100 owner: owner.to_owned(),
101 name: name.to_owned(),
102 number: number.parse().map_err(|_| refused())?,
103 })
104 }
105}
106
107impl fmt::Display for IssueRef {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 write!(f, "{}/{}#{}", self.owner, self.name, self.number)
110 }
111}
112
113impl Serialize for IssueRef {
114 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
115 serializer.collect_str(self)
116 }
117}
118
119impl<'de> Deserialize<'de> for IssueRef {
120 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
121 String::deserialize(deserializer)?
122 .parse()
123 .map_err(serde::de::Error::custom)
124 }
125}
126
127impl JsonSchema for IssueRef {
128 fn schema_name() -> std::borrow::Cow<'static, str> {
129 "IssueRef".into()
130 }
131
132 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
133 json_schema!({
134 "type": "string",
135 "pattern": ISSUE_REF_PATTERN,
136 "description": "One issue of one repository, as `owner/name#N`.",
137 })
138 }
139}
140
141/// What one source holds for one issue a landing closed, and what moving it to `done` would
142/// write — the answer to [`TaskSource::issue_holdings`](crate::TaskSource::issue_holdings).
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
144pub struct IssueHolding {
145 /// The issue asked about.
146 pub issue: IssueRef,
147 /// What completing the issue comes to here, its tag and members beside the issue.
148 #[serde(flatten)]
149 pub completion: Completion,
150}
151
152/// The task one source holds for an issue, as far as moving it to `done` needs it.
153///
154/// It carries of the task exactly what the write and the rules around it need — which task, the
155/// card it sits at, the tasks it delivers, and what decides where its id may be written — rather
156/// than the whole task, because the read that answers it is priced per issue and a whole task
157/// is not what a landing's closure needs.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
159pub struct HeldTask {
160 /// The task, by this source's own id for it.
161 pub id: NativeId,
162 /// The name of the option the task's card sits at, as the source spells it, when it sits
163 /// at one.
164 #[serde(default)]
165 pub option: Option<String>,
166 /// The task's own classification.
167 #[serde(default)]
168 pub classification: Classification,
169 /// The repositories the task names.
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub repositories: Vec<Repository>,
172 /// The tasks it delivers, which a write of it to `done` keeps in step.
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub delivers: Vec<TaskRef>,
175}
176
177/// What moving one issue's item to `done` comes to at one source — the task it holds riding on
178/// every outcome that has one, so none can be told without its task, or with one, that has not.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(tag = "completion", rename_all = "kebab-case")]
181pub enum Completion {
182 /// The source holds no item for the issue, or cannot see the issue at all.
183 Absent,
184 /// The issue's closure already says something else, which a landing does not overrule.
185 Skipped {
186 /// The task held for the issue.
187 task: HeldTask,
188 /// Why, as `closed as <reason>`.
189 reason: String,
190 },
191 /// The issue is closed as completed and its card already sits at the `done` option.
192 Unchanged {
193 /// The task held for the issue.
194 task: HeldTask,
195 },
196 /// The item is to be written `done`: its card moved to the `done` option, and its issue
197 /// closed as completed if it is still open.
198 Due {
199 /// The task held for the issue.
200 task: HeldTask,
201 /// The name of the option `done` writes it at.
202 to: String,
203 },
204}
205
206/// The two closures a card is brought in line with: the categories a closed issue's reason can
207/// read as that name an option to move its card to.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "kebab-case")]
210pub enum ClosedAs {
211 /// Closed as completed, or with no reason: `done`.
212 Done,
213 /// Closed as not planned or as a duplicate: `cancelled`.
214 Cancelled,
215}
216
217impl ClosedAs {
218 /// The status category this closure reads as.
219 #[must_use]
220 pub const fn category(self) -> StatusCategory {
221 match self {
222 Self::Done => StatusCategory::Done,
223 Self::Cancelled => StatusCategory::Cancelled,
224 }
225 }
226}
227
228/// One item whose card contradicts its issue's closure, and the option that brings it in line
229/// — one entry of [`TaskSource::status_contradictions`](crate::TaskSource::status_contradictions).
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
231pub struct StatusContradiction {
232 /// The task, as it reads now.
233 pub task: Task,
234 /// The option its card sits at, or `None` for a card with no option.
235 #[serde(default)]
236 pub option: Option<String>,
237 /// What its closure reads as.
238 pub closed_as: ClosedAs,
239 /// The name of the option the source's mapping gives that category, which its card is
240 /// moved to.
241 pub to: String,
242}