use serde::Serialize;
use crate::domain::{ProviderType, Section};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKey {
DisplayName,
BaseUrl,
Organization,
Project,
Repository,
Username,
Pat,
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldSpec {
pub key: FieldKey,
pub label: String,
pub help: String,
pub required: bool,
pub secret: bool,
pub default: Option<String>,
}
impl FieldSpec {
fn text(key: FieldKey, label: &str, help: &str, required: bool) -> Self {
FieldSpec { key, label: label.into(), help: help.into(), required, secret: false, default: None }
}
fn secret(label: &str, help: &str) -> Self {
FieldSpec { key: FieldKey::Pat, label: label.into(), help: help.into(), required: true, secret: true, default: None }
}
}
pub fn is_repo_addressed(provider: ProviderType) -> bool {
matches!(
provider,
ProviderType::GitHub | ProviderType::GitLab | ProviderType::AzureDevOps | ProviderType::Bitbucket
)
}
const PICK_LATER: &str = "Leave blank to pick repositories after connecting";
pub fn connection_fields(provider: ProviderType) -> Vec<FieldSpec> {
let mut out = vec![FieldSpec {
key: FieldKey::DisplayName,
label: "Display name".into(),
help: "A label shown in the tab bar and connections list".into(),
required: true,
secret: false,
default: Some(provider.as_str().to_string()),
}];
match provider {
ProviderType::Demo => {}
ProviderType::GitHub => {
out.push(FieldSpec::text(FieldKey::Repository, "Repository (owner/repo)", PICK_LATER, false));
out.push(FieldSpec::secret("Personal access token", "github.com → Settings → Developer settings → Personal access tokens · scope: repo"));
}
ProviderType::AzureDevOps => {
out.push(FieldSpec::text(FieldKey::Organization, "Organization", "The {org} in dev.azure.com/{org}", true));
out.push(FieldSpec::text(FieldKey::Project, "Project", PICK_LATER, false));
out.push(FieldSpec::text(FieldKey::Repository, "Repository", PICK_LATER, false));
out.push(FieldSpec::secret("Personal access token", "dev.azure.com → User settings → Personal access tokens · Code, Work Items, Build"));
}
ProviderType::Linear => {
out.push(FieldSpec::secret("API key", "linear.app → Settings → Security & access → API → Personal API keys"));
}
ProviderType::GitLab => {
out.push(FieldSpec::text(FieldKey::Repository, "Project (group/project)", PICK_LATER, false));
out.push(FieldSpec::secret("Personal access token", "gitlab.com → Preferences → Access Tokens · scope: api"));
}
ProviderType::Jira => {
out.push(FieldSpec::text(FieldKey::BaseUrl, "Site URL", "Your Atlassian site, e.g. https://your-company.atlassian.net", true));
out.push(FieldSpec::text(FieldKey::Project, "Project key", "The prefix on issue keys, e.g. ENG in ENG-123", true));
out.push(FieldSpec::text(FieldKey::Username, "Email", "The email of your Atlassian account", true));
out.push(FieldSpec::secret("API token", "id.atlassian.com → Security → Create and manage API tokens"));
}
ProviderType::Bitbucket => {
out.push(FieldSpec::text(FieldKey::Organization, "Workspace", "The {workspace} in bitbucket.org/{workspace}", true));
out.push(FieldSpec::text(FieldKey::Repository, "Repository (slug)", PICK_LATER, false));
out.push(FieldSpec::text(FieldKey::Username, "Username", "Your Bitbucket username (not your email)", true));
out.push(FieldSpec::secret("App password", "Personal settings → App passwords · Pull requests + Pipelines (read & write)"));
}
}
out
}
pub fn provider_sections(provider: ProviderType) -> Vec<Section> {
match provider {
ProviderType::Linear | ProviderType::Jira => vec![Section::WorkItems],
ProviderType::Bitbucket => vec![Section::PullRequests, Section::Pipelines],
_ => vec![Section::PullRequests, Section::WorkItems, Section::Pipelines],
}
}
pub fn selectable_providers() -> Vec<ProviderType> {
vec![
ProviderType::GitHub,
ProviderType::GitLab,
ProviderType::AzureDevOps,
ProviderType::Bitbucket,
ProviderType::Linear,
ProviderType::Jira,
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_needs_a_token_and_feeds_three_sections() {
let fields = connection_fields(ProviderType::GitHub);
assert_eq!(fields[0].key, FieldKey::DisplayName);
assert!(fields.iter().any(|f| f.key == FieldKey::Pat && f.secret));
assert_eq!(provider_sections(ProviderType::GitHub).len(), 3);
}
#[test]
fn repo_addressed_providers_offer_an_optional_repository_field() {
for provider in selectable_providers().into_iter().filter(|p| is_repo_addressed(*p)) {
let fields = connection_fields(provider);
let repo = fields
.iter()
.find(|f| f.key == FieldKey::Repository)
.unwrap_or_else(|| panic!("{} offers no repository field", provider.as_str()));
assert!(!repo.required, "{} still demands a repository", provider.as_str());
}
for provider in [ProviderType::Jira, ProviderType::Linear] {
assert!(!is_repo_addressed(provider));
assert!(connection_fields(provider).iter().all(|f| f.key != FieldKey::Repository));
}
}
#[test]
fn fields_that_discovery_is_addressed_by_stay_required() {
let required = |provider: ProviderType, key: FieldKey| {
connection_fields(provider).iter().find(|f| f.key == key).is_some_and(|f| f.required)
};
assert!(required(ProviderType::Bitbucket, FieldKey::Organization), "Bitbucket workspace must stay required");
assert!(required(ProviderType::AzureDevOps, FieldKey::Organization), "Azure organization must stay required");
assert!(connection_fields(ProviderType::GitHub).iter().all(|f| f.key != FieldKey::Organization));
assert!(connection_fields(ProviderType::GitLab).iter().all(|f| f.key != FieldKey::Organization));
}
#[test]
fn linear_is_work_items_only_and_just_a_key() {
assert_eq!(provider_sections(ProviderType::Linear), vec![Section::WorkItems]);
let fields = connection_fields(ProviderType::Linear);
assert_eq!(fields.len(), 2);
assert!(fields[1].secret);
}
}