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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17pub enum ExternalProvider {
18 GitHub,
20 GitLab,
22 Forgejo,
24 Gitea,
26 Jira,
28 Linear,
30 Other(String),
32}
33
34impl ExternalProvider {
35 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65pub enum ExternalObjectKind {
66 Issue,
68 PullRequest,
70 Other(String),
72}
73
74impl ExternalObjectKind {
75 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct ExternalIdentity {
99 pub provider: ExternalProvider,
101 pub instance: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub namespace: Option<String>,
106 pub kind: ExternalObjectKind,
108 pub external_id: String,
110}
111
112impl ExternalIdentity {
113 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
204pub enum ExternalLinkRole {
205 Tracks,
207 Relates,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct ExternalLink {
215 pub work: WorkItemId,
217 pub role: ExternalLinkRole,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223pub enum ExternalState {
224 Open,
226 Closed,
228 Merged,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct ExternalObservation {
236 pub state: ExternalState,
238 pub observed_at: DateTime<Utc>,
240 pub observed_by: ActorId,
242}
243
244impl ExternalObservation {
245 #[must_use]
248 pub fn admits(&self, next: ExternalState) -> bool {
249 self.state != ExternalState::Merged || next == ExternalState::Merged
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct ExternalReference {
257 pub id: ExternalReferenceId,
259 pub identity: ExternalIdentity,
261 pub label: String,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub url: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub observation: Option<ExternalObservation>,
269 pub links: BTreeSet<ExternalLink>,
271}
272
273impl ExternalReference {
274 #[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;