use crate::{ActorId, ExternalReferenceId, ValidationError, WorkItemId, validation::invalid};
use chrono::{DateTime, Utc};
use provider::{IdForm, NamespaceRule};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
mod canonical;
mod credentials;
mod provider;
mod validation;
pub use canonical::ObjectKey;
pub(crate) use validation::validate;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ExternalProvider {
GitHub,
GitLab,
Forgejo,
Gitea,
Jira,
Linear,
Other(String),
}
impl ExternalProvider {
pub fn parse(name: &str) -> Self {
let name = name.trim().to_lowercase();
match name.as_str() {
"github" => Self::GitHub,
"gitlab" => Self::GitLab,
"forgejo" => Self::Forgejo,
"gitea" => Self::Gitea,
"jira" => Self::Jira,
"linear" => Self::Linear,
_ => Self::Other(name),
}
}
fn display_name(&self) -> &str {
match self {
Self::Other(name) => name,
Self::GitHub => "GitHub",
Self::GitLab => "GitLab",
Self::Forgejo => "Forgejo",
Self::Gitea => "Gitea",
Self::Jira => "Jira",
Self::Linear => "Linear",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ExternalObjectKind {
Issue,
PullRequest,
Other(String),
}
impl ExternalObjectKind {
pub fn parse(name: &str) -> Self {
provider::neutral_kind(name)
}
fn name(&self) -> &str {
match self {
Self::Issue => "issue",
Self::PullRequest => "pull_request",
Self::Other(name) => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalIdentity {
pub provider: ExternalProvider,
pub instance: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
pub kind: ExternalObjectKind,
pub external_id: String,
}
impl ExternalIdentity {
pub fn validate(&self, id: ExternalReferenceId) -> Result<(), ValidationError> {
if self.instance.contains('@') {
return Err(invalid(
"external identity",
id,
"shared plans cannot carry credentials in the instance",
));
}
let canonical = self.canonical();
canonical.validate_parts(id)?;
if canonical != *self {
return Err(invalid(
"external identity",
id,
format!("identity is not canonical; use {canonical}"),
));
}
Ok(())
}
fn validate_parts(&self, id: ExternalReferenceId) -> Result<(), ValidationError> {
let fail = |reason: &str| Err(invalid("external identity", id, reason));
if !valid_instance(&self.instance) {
return fail(
"instance must be a lowercase host[:port] without scheme or path, port 1-65535",
);
}
if let ExternalProvider::Other(name) = &self.provider
&& !lowercase_word(name)
{
return fail("provider name must be a lowercase word");
}
if let ExternalObjectKind::Other(name) = &self.kind
&& !lowercase_word(name)
{
return fail("object kind name must be a lowercase word");
}
let rules = self.provider.rules();
match (self.namespace.as_deref(), rules.namespace) {
(None, NamespaceRule::Required) => {
return fail("this provider scopes identifiers by namespace; supply it");
}
(Some(_), NamespaceRule::Forbidden) => {
return fail("this provider's keys are unique per instance; omit the namespace");
}
(Some(namespace), _) if !valid_namespace(namespace) => {
return fail("namespace must be slash-separated names without `..` or credentials");
}
_ => {}
}
if self.kind == ExternalObjectKind::PullRequest && !rules.reviews {
return fail("this provider has no pull requests");
}
if rules.space(&self.kind).is_none() {
return Err(invalid(
"external identity",
id,
format!(
"{} has no {} kind with a known number space; use {}",
self.provider.display_name(),
self.kind.name(),
rules.accepted_kinds()
),
));
}
if !valid_external_id(rules.ids, &self.external_id) {
return fail(match rules.ids {
IdForm::Number => "this provider numbers objects; use a positive number such as 42",
IdForm::Key => "this provider keys objects as PROJECT-N, such as PROJ-6",
IdForm::Raw => "external id must be a nonempty provider identifier",
});
}
Ok(())
}
}
impl std::fmt::Display for ExternalIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.provider.display_name(), self.instance)?;
if let Some(namespace) = &self.namespace {
write!(f, "/{namespace}")?;
}
write!(f, ":{}:{}", self.kind.name(), self.external_id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ExternalLinkRole {
Tracks,
Relates,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalLink {
pub work: WorkItemId,
pub role: ExternalLinkRole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExternalState {
Open,
Closed,
Merged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalObservation {
pub state: ExternalState,
pub observed_at: DateTime<Utc>,
pub observed_by: ActorId,
}
impl ExternalObservation {
#[must_use]
pub fn admits(&self, next: ExternalState) -> bool {
self.state != ExternalState::Merged || next == ExternalState::Merged
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalReference {
pub id: ExternalReferenceId,
pub identity: ExternalIdentity,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observation: Option<ExternalObservation>,
pub links: BTreeSet<ExternalLink>,
}
impl ExternalReference {
#[must_use]
pub fn tracking_owner(&self) -> Option<WorkItemId> {
self.links
.iter()
.find(|link| link.role == ExternalLinkRole::Tracks)
.map(|link| link.work)
}
}
fn valid_external_id(form: IdForm, id: &str) -> bool {
let positive = |digits: &str| {
digits.starts_with(|c: char| ('1'..='9').contains(&c))
&& digits.chars().all(|c| c.is_ascii_digit())
};
match form {
IdForm::Number => positive(id),
IdForm::Key => id.rsplit_once('-').is_some_and(|(project, number)| {
project.starts_with(|c: char| c.is_ascii_uppercase())
&& project
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& positive(number)
}),
IdForm::Raw => {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '~' | '-'))
}
}
}
fn valid_instance(instance: &str) -> bool {
let (host, port) = instance.split_once(':').unwrap_or((instance, ""));
let port_in_range = port.is_empty()
|| (!port.starts_with('0') && port.parse::<u16>().is_ok_and(|port| port > 0));
let host_ok = !host.is_empty()
&& !host.starts_with(['.', '-'])
&& !host.ends_with(['.', '-'])
&& host
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-'));
let port_ok = !instance.contains(':')
|| (!port.is_empty() && port.len() <= 5 && port.chars().all(|c| c.is_ascii_digit()));
host_ok && port_ok && port_in_range
}
fn valid_namespace(namespace: &str) -> bool {
namespace.split('/').all(|segment| {
!matches!(segment, "" | "." | "..")
&& segment
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '.' | '_' | '~' | '-'))
})
}
fn lowercase_word(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
}
#[cfg(test)]
mod tests;