Skip to main content

dpm_model/
tracking.rs

1use crate::{ActorId, ExternalReferenceId, ValidationError, WorkItemId, validation::invalid};
2use chrono::{DateTime, Utc};
3use provider::{IdForm, NamespaceRule};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeSet;
6
7mod canonical;
8mod credentials;
9mod provider;
10mod validation;
11pub use canonical::ObjectKey;
12pub(crate) use validation::validate;
13
14/// Tracker or forge family; its row in the provider table decides namespaces, kinds, number
15/// spaces and identifier spelling.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17pub enum ExternalProvider {
18    /// GitHub, hosted or Enterprise Server.
19    GitHub,
20    /// GitLab, hosted or self-managed.
21    GitLab,
22    /// A Forgejo instance.
23    Forgejo,
24    /// A Gitea instance.
25    Gitea,
26    /// Jira, Cloud or Data Center.
27    Jira,
28    /// Linear.
29    Linear,
30    /// Another family, named in lowercase.
31    Other(String),
32}
33
34impl ExternalProvider {
35    /// Case-insensitive family name; unknown names become a lowercase `Other` family.
36    pub fn parse(name: &str) -> Self {
37        let name = name.trim().to_lowercase();
38        match name.as_str() {
39            "github" => Self::GitHub,
40            "gitlab" => Self::GitLab,
41            "forgejo" => Self::Forgejo,
42            "gitea" => Self::Gitea,
43            "jira" => Self::Jira,
44            "linear" => Self::Linear,
45            _ => Self::Other(name),
46        }
47    }
48
49    fn display_name(&self) -> &str {
50        match self {
51            Self::Other(name) => name,
52            Self::GitHub => "GitHub",
53            Self::GitLab => "GitLab",
54            Self::Forgejo => "Forgejo",
55            Self::Gitea => "Gitea",
56            Self::Jira => "Jira",
57            Self::Linear => "Linear",
58        }
59    }
60}
61
62/// Category of external object. The provider table maps each kind to a number space, so a kind
63/// separates identity only where the provider numbers it separately.
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65pub enum ExternalObjectKind {
66    /// An issue or ticket.
67    Issue,
68    /// A pull request or merge request.
69    PullRequest,
70    /// Another provider object category, named in lowercase.
71    Other(String),
72}
73
74impl ExternalObjectKind {
75    /// Provider-neutral kind, folded by the one kind normalizer: case, separators and one plural
76    /// ending are ignored, so `pull_requests`, `PRs` and `merge-request` are `PullRequest`,
77    /// `issues` is `Issue` and `Work Items` is `{"Other":"workitem"}`. Canonicalization then maps
78    /// the name onto the provider's own kind table.
79    pub fn parse(name: &str) -> Self {
80        provider::neutral_kind(name)
81    }
82
83    fn name(&self) -> &str {
84        match self {
85            Self::Issue => "issue",
86            Self::PullRequest => "pull_request",
87            Self::Other(name) => name,
88        }
89    }
90}
91
92/// Identity of an external object, independent of its display label and URL.
93///
94/// Two references name one object when their [`ObjectKey`]s are equal, so equal numbers from
95/// different instances, namespaces or number spaces never collide.
96#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct ExternalIdentity {
99    /// Tracker family.
100    pub provider: ExternalProvider,
101    /// Lowercase `host[:port]` of the hosted or self-hosted instance, without scheme or path.
102    pub instance: String,
103    /// Tenant, owner/repository or project namespace where the provider scopes identifiers.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub namespace: Option<String>,
106    /// Object category; it splits identity only between number spaces.
107    pub kind: ExternalObjectKind,
108    /// Provider identifier: a number on forges, a `PROJECT-N` key on Jira and Linear.
109    pub external_id: String,
110}
111
112impl ExternalIdentity {
113    /// Check that this identity is canonical and structurally valid; commands check a requested
114    /// identity with this even when it resolves to an existing record.
115    pub fn validate(&self, id: ExternalReferenceId) -> Result<(), ValidationError> {
116        if self.instance.contains('@') {
117            return Err(invalid(
118                "external identity",
119                id,
120                "shared plans cannot carry credentials in the instance",
121            ));
122        }
123        // Structure is checked on the canonical form so a suggested spelling is always valid.
124        let canonical = self.canonical();
125        canonical.validate_parts(id)?;
126        if canonical != *self {
127            return Err(invalid(
128                "external identity",
129                id,
130                format!("identity is not canonical; use {canonical}"),
131            ));
132        }
133        Ok(())
134    }
135
136    fn validate_parts(&self, id: ExternalReferenceId) -> Result<(), ValidationError> {
137        let fail = |reason: &str| Err(invalid("external identity", id, reason));
138        if !valid_instance(&self.instance) {
139            return fail(
140                "instance must be a lowercase host[:port] without scheme or path, port 1-65535",
141            );
142        }
143        if let ExternalProvider::Other(name) = &self.provider
144            && !lowercase_word(name)
145        {
146            return fail("provider name must be a lowercase word");
147        }
148        if let ExternalObjectKind::Other(name) = &self.kind
149            && !lowercase_word(name)
150        {
151            return fail("object kind name must be a lowercase word");
152        }
153        let rules = self.provider.rules();
154        match (self.namespace.as_deref(), rules.namespace) {
155            (None, NamespaceRule::Required) => {
156                return fail("this provider scopes identifiers by namespace; supply it");
157            }
158            (Some(_), NamespaceRule::Forbidden) => {
159                return fail("this provider's keys are unique per instance; omit the namespace");
160            }
161            (Some(namespace), _) if !valid_namespace(namespace) => {
162                return fail("namespace must be slash-separated names without `..` or credentials");
163            }
164            _ => {}
165        }
166        if self.kind == ExternalObjectKind::PullRequest && !rules.reviews {
167            return fail("this provider has no pull requests");
168        }
169        if rules.space(&self.kind).is_none() {
170            return Err(invalid(
171                "external identity",
172                id,
173                format!(
174                    "{} has no {} kind with a known number space; use {}",
175                    self.provider.display_name(),
176                    self.kind.name(),
177                    rules.accepted_kinds()
178                ),
179            ));
180        }
181        if !valid_external_id(rules.ids, &self.external_id) {
182            return fail(match rules.ids {
183                IdForm::Number => "this provider numbers objects; use a positive number such as 42",
184                IdForm::Key => "this provider keys objects as PROJECT-N, such as PROJ-6",
185                IdForm::Raw => "external id must be a nonempty provider identifier",
186            });
187        }
188        Ok(())
189    }
190}
191
192impl std::fmt::Display for ExternalIdentity {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        write!(f, "{}:{}", self.provider.display_name(), self.instance)?;
195        if let Some(namespace) = &self.namespace {
196            write!(f, "/{namespace}")?;
197        }
198        write!(f, ":{}:{}", self.kind.name(), self.external_id)
199    }
200}
201
202/// How linked work relates to an external object.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
204pub enum ExternalLinkRole {
205    /// This work is the single local owner tracking the external object.
206    Tracks,
207    /// The external object is related context for this work.
208    Relates,
209}
210
211/// One work item's link to an external object; it is context, never evidence or a gate.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct ExternalLink {
215    /// Linked work, referenced by stable identity so key renames keep the link.
216    pub work: WorkItemId,
217    /// Relationship of the work to the external object.
218    pub role: ExternalLinkRole,
219}
220
221/// External lifecycle state as last reported by an actor; the local lifecycle stays authoritative.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223pub enum ExternalState {
224    /// The external object is open.
225    Open,
226    /// The external object is closed.
227    Closed,
228    /// The pull request is merged.
229    Merged,
230}
231
232/// An attributed report of external state; it never changes local work status.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct ExternalObservation {
236    /// Reported state.
237    pub state: ExternalState,
238    /// Time at which the state was recorded.
239    pub observed_at: DateTime<Utc>,
240    /// Principal that reported the state.
241    pub observed_by: ActorId,
242}
243
244impl ExternalObservation {
245    /// Whether a later report of `next` may replace this one. A merged pull request can neither
246    /// reopen nor close, so after `Merged` only another `Merged` report is accepted from any link.
247    #[must_use]
248    pub fn admits(&self, next: ExternalState) -> bool {
249        self.state != ExternalState::Merged || next == ExternalState::Merged
250    }
251}
252
253/// External object known to the workspace, with its display data and local links.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct ExternalReference {
257    /// Stable local identity; it survives relabeling and namespace changes.
258    pub id: ExternalReferenceId,
259    /// Provider identity, unique within the workspace.
260    pub identity: ExternalIdentity,
261    /// Human-readable display label; not part of identity.
262    pub label: String,
263    /// Optional browser address on the identity's instance; not part of identity.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub url: Option<String>,
266    /// Most recent reported external state, if any.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub observation: Option<ExternalObservation>,
269    /// Linked work; at most one link per work item and one `Tracks` link overall.
270    pub links: BTreeSet<ExternalLink>,
271}
272
273impl ExternalReference {
274    /// The work item that tracks this external object, if any.
275    #[must_use]
276    pub fn tracking_owner(&self) -> Option<WorkItemId> {
277        self.links
278            .iter()
279            .find(|link| link.role == ExternalLinkRole::Tracks)
280            .map(|link| link.work)
281    }
282}
283
284fn valid_external_id(form: IdForm, id: &str) -> bool {
285    let positive = |digits: &str| {
286        digits.starts_with(|c: char| ('1'..='9').contains(&c))
287            && digits.chars().all(|c| c.is_ascii_digit())
288    };
289    match form {
290        IdForm::Number => positive(id),
291        IdForm::Key => id.rsplit_once('-').is_some_and(|(project, number)| {
292            project.starts_with(|c: char| c.is_ascii_uppercase())
293                && project
294                    .chars()
295                    .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
296                && positive(number)
297        }),
298        IdForm::Raw => {
299            !id.is_empty()
300                && id
301                    .chars()
302                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '~' | '-'))
303        }
304    }
305}
306
307fn valid_instance(instance: &str) -> bool {
308    let (host, port) = instance.split_once(':').unwrap_or((instance, ""));
309    let port_in_range = port.is_empty()
310        || (!port.starts_with('0') && port.parse::<u16>().is_ok_and(|port| port > 0));
311    let host_ok = !host.is_empty()
312        && !host.starts_with(['.', '-'])
313        && !host.ends_with(['.', '-'])
314        && host
315            .chars()
316            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-'));
317    let port_ok = !instance.contains(':')
318        || (!port.is_empty() && port.len() <= 5 && port.chars().all(|c| c.is_ascii_digit()));
319    host_ok && port_ok && port_in_range
320}
321
322fn valid_namespace(namespace: &str) -> bool {
323    namespace.split('/').all(|segment| {
324        !matches!(segment, "" | "." | "..")
325            && segment
326                .chars()
327                .all(|c| c.is_alphanumeric() || matches!(c, '.' | '_' | '~' | '-'))
328    })
329}
330
331fn lowercase_word(name: &str) -> bool {
332    !name.is_empty()
333        && name
334            .chars()
335            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
336}
337
338#[cfg(test)]
339mod tests;