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::{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}