1use serde::Serialize;
7
8use crate::domain::{ProviderType, Section};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FieldKey {
15 DisplayName,
16 BaseUrl,
17 Organization,
18 Project,
19 Repository,
20 Username,
21 Pat,
22}
23
24#[derive(Debug, Clone, Serialize)]
26pub struct FieldSpec {
27 pub key: FieldKey,
28 pub label: String,
29 pub help: String,
30 pub required: bool,
31 pub secret: bool,
33 pub default: Option<String>,
35}
36
37impl FieldSpec {
38 fn text(key: FieldKey, label: &str, help: &str, required: bool) -> Self {
39 FieldSpec { key, label: label.into(), help: help.into(), required, secret: false, default: None }
40 }
41 fn secret(label: &str, help: &str) -> Self {
42 FieldSpec { key: FieldKey::Pat, label: label.into(), help: help.into(), required: true, secret: true, default: None }
43 }
44}
45
46pub fn is_repo_addressed(provider: ProviderType) -> bool {
54 matches!(
55 provider,
56 ProviderType::GitHub | ProviderType::GitLab | ProviderType::AzureDevOps | ProviderType::Bitbucket
57 )
58}
59
60const PICK_LATER: &str = "Leave blank to pick repositories after connecting";
63
64pub fn connection_fields(provider: ProviderType) -> Vec<FieldSpec> {
72 let mut out = vec![FieldSpec {
73 key: FieldKey::DisplayName,
74 label: "Display name".into(),
75 help: "A label shown in the tab bar and connections list".into(),
76 required: true,
77 secret: false,
78 default: Some(provider.as_str().to_string()),
79 }];
80 match provider {
81 ProviderType::Demo => {}
82 ProviderType::GitHub => {
83 out.push(FieldSpec::text(FieldKey::Repository, "Repository (owner/repo)", PICK_LATER, false));
84 out.push(FieldSpec::secret("Personal access token", "github.com → Settings → Developer settings → Personal access tokens · scope: repo"));
85 }
86 ProviderType::AzureDevOps => {
87 out.push(FieldSpec::text(FieldKey::Organization, "Organization", "The {org} in dev.azure.com/{org}", true));
88 out.push(FieldSpec::text(FieldKey::Project, "Project", PICK_LATER, false));
89 out.push(FieldSpec::text(FieldKey::Repository, "Repository", PICK_LATER, false));
90 out.push(FieldSpec::secret("Personal access token", "dev.azure.com → User settings → Personal access tokens · Code, Work Items, Build"));
91 }
92 ProviderType::Linear => {
93 out.push(FieldSpec::secret("API key", "linear.app → Settings → Security & access → API → Personal API keys"));
94 }
95 ProviderType::GitLab => {
96 out.push(FieldSpec::text(FieldKey::Repository, "Project (group/project)", PICK_LATER, false));
97 out.push(FieldSpec::secret("Personal access token", "gitlab.com → Preferences → Access Tokens · scope: api"));
98 }
99 ProviderType::Jira => {
100 out.push(FieldSpec::text(FieldKey::BaseUrl, "Site URL", "Your Atlassian site, e.g. https://your-company.atlassian.net", true));
101 out.push(FieldSpec::text(FieldKey::Project, "Project key", "The prefix on issue keys, e.g. ENG in ENG-123", true));
102 out.push(FieldSpec::text(FieldKey::Username, "Email", "The email of your Atlassian account", true));
103 out.push(FieldSpec::secret("API token", "id.atlassian.com → Security → Create and manage API tokens"));
104 }
105 ProviderType::Bitbucket => {
106 out.push(FieldSpec::text(FieldKey::Organization, "Workspace", "The {workspace} in bitbucket.org/{workspace}", true));
107 out.push(FieldSpec::text(FieldKey::Repository, "Repository (slug)", PICK_LATER, false));
108 out.push(FieldSpec::text(FieldKey::Username, "Username", "Your Bitbucket username (not your email)", true));
109 out.push(FieldSpec::secret("App password", "Personal settings → App passwords · Pull requests + Pipelines (read & write)"));
110 }
111 }
112 out
113}
114
115pub fn provider_sections(provider: ProviderType) -> Vec<Section> {
117 match provider {
118 ProviderType::Linear | ProviderType::Jira => vec![Section::WorkItems],
119 ProviderType::Bitbucket => vec![Section::PullRequests, Section::Pipelines],
120 _ => vec![Section::PullRequests, Section::WorkItems, Section::Pipelines],
121 }
122}
123
124pub fn selectable_providers() -> Vec<ProviderType> {
126 vec![
127 ProviderType::GitHub,
128 ProviderType::GitLab,
129 ProviderType::AzureDevOps,
130 ProviderType::Bitbucket,
131 ProviderType::Linear,
132 ProviderType::Jira,
133 ]
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn github_needs_a_token_and_feeds_three_sections() {
142 let fields = connection_fields(ProviderType::GitHub);
143 assert_eq!(fields[0].key, FieldKey::DisplayName);
144 assert!(fields.iter().any(|f| f.key == FieldKey::Pat && f.secret));
145 assert_eq!(provider_sections(ProviderType::GitHub).len(), 3);
146 }
147
148 #[test]
151 fn repo_addressed_providers_offer_an_optional_repository_field() {
152 for provider in selectable_providers().into_iter().filter(|p| is_repo_addressed(*p)) {
153 let fields = connection_fields(provider);
154 let repo = fields
155 .iter()
156 .find(|f| f.key == FieldKey::Repository)
157 .unwrap_or_else(|| panic!("{} offers no repository field", provider.as_str()));
158 assert!(!repo.required, "{} still demands a repository", provider.as_str());
159 }
160 for provider in [ProviderType::Jira, ProviderType::Linear] {
162 assert!(!is_repo_addressed(provider));
163 assert!(connection_fields(provider).iter().all(|f| f.key != FieldKey::Repository));
164 }
165 }
166
167 #[test]
170 fn fields_that_discovery_is_addressed_by_stay_required() {
171 let required = |provider: ProviderType, key: FieldKey| {
172 connection_fields(provider).iter().find(|f| f.key == key).is_some_and(|f| f.required)
173 };
174 assert!(required(ProviderType::Bitbucket, FieldKey::Organization), "Bitbucket workspace must stay required");
176 assert!(required(ProviderType::AzureDevOps, FieldKey::Organization), "Azure organization must stay required");
178 assert!(connection_fields(ProviderType::GitHub).iter().all(|f| f.key != FieldKey::Organization));
180 assert!(connection_fields(ProviderType::GitLab).iter().all(|f| f.key != FieldKey::Organization));
181 }
182
183 #[test]
184 fn linear_is_work_items_only_and_just_a_key() {
185 assert_eq!(provider_sections(ProviderType::Linear), vec![Section::WorkItems]);
186 let fields = connection_fields(ProviderType::Linear);
187 assert_eq!(fields.len(), 2);
189 assert!(fields[1].secret);
190 }
191}