@mbe24/99problems 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
- package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
- package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/dependabot.yml +20 -0
- package/.github/scripts/publish.js +1 -1
- package/.github/workflows/ci.yml +32 -6
- package/.github/workflows/man-drift.yml +26 -0
- package/.github/workflows/release.yml +7 -7
- package/CONTRIBUTING.md +38 -50
- package/Cargo.lock +151 -108
- package/Cargo.toml +8 -3
- package/README.md +109 -72
- package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
- package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
- package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
- package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
- package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
- package/docs/man/99problems-completions.1 +31 -0
- package/docs/man/99problems-config.1 +50 -0
- package/docs/man/99problems-get.1 +114 -0
- package/docs/man/99problems-help.1 +25 -0
- package/docs/man/99problems-man.1 +32 -0
- package/docs/man/99problems.1 +52 -0
- package/npm/install.js +90 -3
- package/package.json +7 -7
- package/rust-toolchain.toml +4 -0
- package/src/cmd/config/key.rs +126 -0
- package/src/cmd/config/mod.rs +218 -0
- package/src/cmd/config/render.rs +33 -0
- package/src/cmd/config/store.rs +318 -0
- package/src/cmd/config/write.rs +173 -0
- package/src/cmd/get.rs +658 -0
- package/src/cmd/man.rs +117 -0
- package/src/cmd/mod.rs +3 -0
- package/src/config.rs +641 -42
- package/src/error.rs +254 -0
- package/src/format/json.rs +59 -18
- package/src/format/jsonl.rs +52 -0
- package/src/format/mod.rs +25 -3
- package/src/format/text.rs +73 -0
- package/src/format/yaml.rs +64 -15
- package/src/lib.rs +1 -0
- package/src/logging.rs +54 -0
- package/src/main.rs +230 -91
- package/src/model.rs +9 -1
- package/src/source/bitbucket/cloud/api.rs +67 -0
- package/src/source/bitbucket/cloud/mod.rs +178 -0
- package/src/source/bitbucket/cloud/model.rs +211 -0
- package/src/source/bitbucket/datacenter/api.rs +74 -0
- package/src/source/bitbucket/datacenter/mod.rs +181 -0
- package/src/source/bitbucket/datacenter/model.rs +327 -0
- package/src/source/bitbucket/mod.rs +90 -0
- package/src/source/bitbucket/query.rs +169 -0
- package/src/source/bitbucket/shared/auth.rs +54 -0
- package/src/source/bitbucket/shared/http.rs +59 -0
- package/src/source/bitbucket/shared/mod.rs +5 -0
- package/src/source/github/api.rs +128 -0
- package/src/source/github/mod.rs +191 -0
- package/src/source/github/model.rs +84 -0
- package/src/source/github/query.rs +50 -0
- package/src/source/gitlab/api.rs +282 -0
- package/src/source/gitlab/mod.rs +225 -0
- package/src/source/gitlab/model.rs +102 -0
- package/src/source/gitlab/query.rs +177 -0
- package/src/source/jira/api.rs +222 -0
- package/src/source/jira/mod.rs +161 -0
- package/src/source/jira/model.rs +99 -0
- package/src/source/jira/query.rs +153 -0
- package/src/source/mod.rs +179 -24
- package/tests/integration.rs +406 -26
- package/src/source/github_issues.rs +0 -232
package/src/config.rs
CHANGED
|
@@ -1,49 +1,307 @@
|
|
|
1
|
-
use anyhow::Result;
|
|
1
|
+
use anyhow::{Result, anyhow};
|
|
2
2
|
use serde::Deserialize;
|
|
3
|
+
use std::collections::HashMap;
|
|
3
4
|
use std::path::PathBuf;
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
pub
|
|
6
|
+
#[derive(Debug, Default, Deserialize, Clone)]
|
|
7
|
+
pub struct InstanceConfig {
|
|
8
|
+
pub platform: Option<String>,
|
|
8
9
|
pub token: Option<String>,
|
|
10
|
+
pub account_email: Option<String>,
|
|
11
|
+
pub url: Option<String>,
|
|
9
12
|
pub repo: Option<String>,
|
|
10
13
|
pub state: Option<String>,
|
|
14
|
+
#[serde(rename = "type")]
|
|
15
|
+
pub kind: Option<String>,
|
|
16
|
+
pub deployment: Option<String>,
|
|
11
17
|
pub per_page: Option<u32>,
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
#[derive(Debug, Default, Deserialize, Clone)]
|
|
21
|
+
pub struct DotfileConfig {
|
|
22
|
+
pub default_instance: Option<String>,
|
|
23
|
+
#[serde(default)]
|
|
24
|
+
pub instances: HashMap<String, InstanceConfig>,
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
#[derive(Debug, Default)]
|
|
16
28
|
pub struct Config {
|
|
29
|
+
pub platform: String,
|
|
30
|
+
pub kind: String,
|
|
31
|
+
pub kind_explicit: bool,
|
|
17
32
|
pub token: Option<String>,
|
|
33
|
+
pub account_email: Option<String>,
|
|
18
34
|
pub repo: Option<String>,
|
|
19
35
|
pub state: Option<String>,
|
|
36
|
+
pub deployment: Option<String>,
|
|
20
37
|
pub per_page: u32,
|
|
38
|
+
pub platform_url: Option<String>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[derive(Debug, Default, Clone, Copy)]
|
|
42
|
+
pub struct ResolveOptions<'a> {
|
|
43
|
+
pub platform: Option<&'a str>,
|
|
44
|
+
pub instance: Option<&'a str>,
|
|
45
|
+
pub url: Option<&'a str>,
|
|
46
|
+
pub kind: Option<&'a str>,
|
|
47
|
+
pub deployment: Option<&'a str>,
|
|
48
|
+
pub token: Option<&'a str>,
|
|
49
|
+
pub account_email: Option<&'a str>,
|
|
50
|
+
pub repo: Option<&'a str>,
|
|
51
|
+
pub state: Option<&'a str>,
|
|
21
52
|
}
|
|
22
53
|
|
|
23
54
|
impl Config {
|
|
24
|
-
/// Load and
|
|
25
|
-
///
|
|
55
|
+
/// Load and resolve config from dotfiles, env vars, and defaults.
|
|
56
|
+
///
|
|
57
|
+
/// # Errors
|
|
58
|
+
///
|
|
59
|
+
/// Returns an error if reading/parsing dotfiles fails, if unsupported
|
|
60
|
+
/// dotfile keys are present, or if instance selection/validation fails.
|
|
26
61
|
pub fn load() -> Result<Self> {
|
|
62
|
+
Self::load_with_options(ResolveOptions::default())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Load config with CLI-provided overrides and selectors.
|
|
66
|
+
///
|
|
67
|
+
/// # Errors
|
|
68
|
+
///
|
|
69
|
+
/// Returns an error if reading/parsing dotfiles fails, if unsupported
|
|
70
|
+
/// dotfile keys are present, or if instance selection/validation fails.
|
|
71
|
+
pub fn load_with_options(opts: ResolveOptions<'_>) -> Result<Self> {
|
|
27
72
|
let home = load_dotfile(home_dotfile_path())?;
|
|
28
73
|
let local = load_dotfile(local_dotfile_path())?;
|
|
74
|
+
resolve_from_dotfiles(home, local, opts)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[must_use]
|
|
79
|
+
fn merge_instance(base: &InstanceConfig, override_cfg: &InstanceConfig) -> InstanceConfig {
|
|
80
|
+
InstanceConfig {
|
|
81
|
+
platform: override_cfg
|
|
82
|
+
.platform
|
|
83
|
+
.clone()
|
|
84
|
+
.or_else(|| base.platform.clone()),
|
|
85
|
+
token: override_cfg.token.clone().or_else(|| base.token.clone()),
|
|
86
|
+
account_email: override_cfg
|
|
87
|
+
.account_email
|
|
88
|
+
.clone()
|
|
89
|
+
.or_else(|| base.account_email.clone()),
|
|
90
|
+
url: override_cfg.url.clone().or_else(|| base.url.clone()),
|
|
91
|
+
repo: override_cfg.repo.clone().or_else(|| base.repo.clone()),
|
|
92
|
+
state: override_cfg.state.clone().or_else(|| base.state.clone()),
|
|
93
|
+
kind: override_cfg.kind.clone().or_else(|| base.kind.clone()),
|
|
94
|
+
deployment: override_cfg
|
|
95
|
+
.deployment
|
|
96
|
+
.clone()
|
|
97
|
+
.or_else(|| base.deployment.clone()),
|
|
98
|
+
per_page: override_cfg.per_page.or(base.per_page),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[must_use]
|
|
103
|
+
fn merge_dotfiles(home: DotfileConfig, local: DotfileConfig) -> DotfileConfig {
|
|
104
|
+
let mut merged_instances = home.instances;
|
|
105
|
+
for (alias, local_cfg) in local.instances {
|
|
106
|
+
merged_instances
|
|
107
|
+
.entry(alias)
|
|
108
|
+
.and_modify(|home_cfg| *home_cfg = merge_instance(home_cfg, &local_cfg))
|
|
109
|
+
.or_insert(local_cfg);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
DotfileConfig {
|
|
113
|
+
default_instance: local.default_instance.or(home.default_instance),
|
|
114
|
+
instances: merged_instances,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn resolve_from_dotfiles(
|
|
119
|
+
home: DotfileConfig,
|
|
120
|
+
local: DotfileConfig,
|
|
121
|
+
opts: ResolveOptions<'_>,
|
|
122
|
+
) -> Result<Config> {
|
|
123
|
+
let merged = merge_dotfiles(home, local);
|
|
124
|
+
|
|
125
|
+
if !merged.instances.is_empty() {
|
|
126
|
+
return resolve_instance_mode(&merged, opts);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if let Some(alias) = opts.instance {
|
|
130
|
+
return Err(anyhow!("Instance '{alias}' not found."));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if let Some(default_instance) = merged.default_instance {
|
|
134
|
+
return Err(anyhow!(
|
|
135
|
+
"default_instance is set to '{default_instance}', but no instances are configured."
|
|
136
|
+
));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
resolve_cli_only_mode(opts)
|
|
140
|
+
}
|
|
29
141
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
142
|
+
fn resolve_instance_mode(merged: &DotfileConfig, opts: ResolveOptions<'_>) -> Result<Config> {
|
|
143
|
+
if let Some(default_instance) = merged.default_instance.as_deref()
|
|
144
|
+
&& !merged.instances.contains_key(default_instance)
|
|
145
|
+
{
|
|
146
|
+
return Err(anyhow!(
|
|
147
|
+
"default_instance '{default_instance}' was not found in instances."
|
|
148
|
+
));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let selected_alias = if let Some(alias) = opts.instance {
|
|
152
|
+
if merged.instances.contains_key(alias) {
|
|
153
|
+
alias.to_string()
|
|
154
|
+
} else {
|
|
155
|
+
return Err(anyhow!(
|
|
156
|
+
"Instance '{alias}' not found. Available: {}",
|
|
157
|
+
available_instances(&merged.instances)
|
|
158
|
+
));
|
|
159
|
+
}
|
|
160
|
+
} else if merged.instances.len() == 1 {
|
|
161
|
+
merged
|
|
162
|
+
.instances
|
|
163
|
+
.keys()
|
|
164
|
+
.next()
|
|
165
|
+
.ok_or_else(|| anyhow!("No instances configured."))?
|
|
166
|
+
.clone()
|
|
167
|
+
} else if let Some(default_instance) = merged.default_instance.as_deref() {
|
|
168
|
+
default_instance.to_string()
|
|
169
|
+
} else {
|
|
170
|
+
return Err(anyhow!(
|
|
171
|
+
"Multiple instances configured. Specify --instance or set default_instance. Available: {}",
|
|
172
|
+
available_instances(&merged.instances)
|
|
173
|
+
));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let instance = merged
|
|
177
|
+
.instances
|
|
178
|
+
.get(&selected_alias)
|
|
179
|
+
.ok_or_else(|| anyhow!("Instance '{selected_alias}' not found."))?;
|
|
180
|
+
let instance_platform = instance.platform.as_deref().ok_or_else(|| {
|
|
181
|
+
anyhow!("Instance '{selected_alias}' is missing required field 'platform'.")
|
|
182
|
+
})?;
|
|
183
|
+
|
|
184
|
+
if let Some(cli_platform) = opts.platform
|
|
185
|
+
&& cli_platform != instance_platform
|
|
186
|
+
{
|
|
187
|
+
return Err(anyhow!(
|
|
188
|
+
"Platform mismatch: --instance '{selected_alias}' uses '{instance_platform}', but --platform was '{cli_platform}'."
|
|
189
|
+
));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let platform = instance_platform.to_string();
|
|
193
|
+
let deployment = normalize_deployment(
|
|
194
|
+
&platform,
|
|
195
|
+
opts.deployment.or(instance.deployment.as_deref()),
|
|
196
|
+
)?;
|
|
197
|
+
let token = opts
|
|
198
|
+
.token
|
|
199
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
200
|
+
.or_else(|| std::env::var(token_env_var(&platform)).ok())
|
|
201
|
+
.or_else(|| instance.token.clone());
|
|
202
|
+
let account_email = opts
|
|
203
|
+
.account_email
|
|
204
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
205
|
+
.or_else(|| account_email_env_var(&platform).and_then(|var| std::env::var(var).ok()))
|
|
206
|
+
.or_else(|| instance.account_email.clone());
|
|
207
|
+
let (kind, kind_explicit) = if let Some(kind) = opts.kind {
|
|
208
|
+
(kind.to_string(), true)
|
|
209
|
+
} else if let Some(kind) = instance.kind.as_deref() {
|
|
210
|
+
(kind.to_string(), true)
|
|
211
|
+
} else {
|
|
212
|
+
("issue".to_string(), false)
|
|
213
|
+
};
|
|
35
214
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
215
|
+
Ok(Config {
|
|
216
|
+
platform,
|
|
217
|
+
kind,
|
|
218
|
+
kind_explicit,
|
|
219
|
+
token,
|
|
220
|
+
account_email,
|
|
221
|
+
repo: opts
|
|
222
|
+
.repo
|
|
223
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
224
|
+
.or_else(|| instance.repo.clone()),
|
|
225
|
+
state: opts
|
|
226
|
+
.state
|
|
227
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
228
|
+
.or_else(|| instance.state.clone()),
|
|
229
|
+
deployment,
|
|
230
|
+
per_page: instance.per_page.unwrap_or(100),
|
|
231
|
+
platform_url: opts
|
|
232
|
+
.url
|
|
233
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
234
|
+
.or_else(|| instance.url.clone()),
|
|
235
|
+
})
|
|
236
|
+
}
|
|
39
237
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
238
|
+
fn resolve_cli_only_mode(opts: ResolveOptions<'_>) -> Result<Config> {
|
|
239
|
+
let platform = opts.platform.unwrap_or("github").to_string();
|
|
240
|
+
if !matches!(
|
|
241
|
+
platform.as_str(),
|
|
242
|
+
"github" | "gitlab" | "jira" | "bitbucket"
|
|
243
|
+
) {
|
|
244
|
+
return Err(anyhow!("Platform '{platform}' is not yet supported."));
|
|
46
245
|
}
|
|
246
|
+
let deployment = normalize_deployment(&platform, opts.deployment)?;
|
|
247
|
+
|
|
248
|
+
let token = opts
|
|
249
|
+
.token
|
|
250
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
251
|
+
.or_else(|| std::env::var(token_env_var(&platform)).ok());
|
|
252
|
+
let account_email = opts
|
|
253
|
+
.account_email
|
|
254
|
+
.map(std::borrow::ToOwned::to_owned)
|
|
255
|
+
.or_else(|| account_email_env_var(&platform).and_then(|var| std::env::var(var).ok()));
|
|
256
|
+
let (kind, kind_explicit) = if let Some(kind) = opts.kind {
|
|
257
|
+
(kind.to_string(), true)
|
|
258
|
+
} else {
|
|
259
|
+
("issue".to_string(), false)
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
Ok(Config {
|
|
263
|
+
platform,
|
|
264
|
+
kind,
|
|
265
|
+
kind_explicit,
|
|
266
|
+
token,
|
|
267
|
+
account_email,
|
|
268
|
+
repo: opts.repo.map(std::borrow::ToOwned::to_owned),
|
|
269
|
+
state: opts.state.map(std::borrow::ToOwned::to_owned),
|
|
270
|
+
deployment,
|
|
271
|
+
per_page: 100,
|
|
272
|
+
platform_url: opts.url.map(std::borrow::ToOwned::to_owned),
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn normalize_deployment(platform: &str, deployment: Option<&str>) -> Result<Option<String>> {
|
|
277
|
+
if platform == "bitbucket" {
|
|
278
|
+
let deployment = deployment.ok_or_else(|| {
|
|
279
|
+
anyhow!(
|
|
280
|
+
"Bitbucket deployment is required. Set [instances.<alias>].deployment or pass --deployment (cloud|selfhosted)."
|
|
281
|
+
)
|
|
282
|
+
})?;
|
|
283
|
+
return match deployment {
|
|
284
|
+
"cloud" | "selfhosted" => Ok(Some(deployment.to_string())),
|
|
285
|
+
other => Err(anyhow!(
|
|
286
|
+
"Invalid bitbucket deployment '{other}'. Supported: cloud, selfhosted."
|
|
287
|
+
)),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if let Some(value) = deployment {
|
|
292
|
+
return Err(anyhow!(
|
|
293
|
+
"Deployment is only supported for platform 'bitbucket', got '{platform}' with deployment '{value}'."
|
|
294
|
+
));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
Ok(None)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[must_use]
|
|
301
|
+
fn available_instances(instances: &HashMap<String, InstanceConfig>) -> String {
|
|
302
|
+
let mut keys: Vec<&str> = instances.keys().map(std::string::String::as_str).collect();
|
|
303
|
+
keys.sort_unstable();
|
|
304
|
+
keys.join(", ")
|
|
47
305
|
}
|
|
48
306
|
|
|
49
307
|
fn home_dotfile_path() -> Option<PathBuf> {
|
|
@@ -60,42 +318,383 @@ fn load_dotfile(path: Option<PathBuf>) -> Result<DotfileConfig> {
|
|
|
60
318
|
_ => return Ok(DotfileConfig::default()),
|
|
61
319
|
};
|
|
62
320
|
let content = std::fs::read_to_string(&path)?;
|
|
63
|
-
|
|
321
|
+
parse_dotfile_content(&content)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fn parse_dotfile_content(content: &str) -> Result<DotfileConfig> {
|
|
325
|
+
let value: toml::Value = toml::from_str(content)?;
|
|
326
|
+
let table = value
|
|
327
|
+
.as_table()
|
|
328
|
+
.ok_or_else(|| anyhow!("Invalid .99problems: expected top-level TOML table."))?;
|
|
329
|
+
validate_dotfile_keys(table)?;
|
|
330
|
+
let cfg: DotfileConfig = toml::from_str(content)?;
|
|
64
331
|
Ok(cfg)
|
|
65
332
|
}
|
|
66
333
|
|
|
334
|
+
fn validate_dotfile_keys(table: &toml::value::Table) -> Result<()> {
|
|
335
|
+
for key in [
|
|
336
|
+
"platform",
|
|
337
|
+
"repo",
|
|
338
|
+
"state",
|
|
339
|
+
"type",
|
|
340
|
+
"per_page",
|
|
341
|
+
"account_email",
|
|
342
|
+
"deployment",
|
|
343
|
+
] {
|
|
344
|
+
if table.contains_key(key) {
|
|
345
|
+
return Err(anyhow!("Unsupported top-level key '{key}' in .99problems."));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for key in ["github", "gitlab", "jira", "bitbucket"] {
|
|
349
|
+
if table.contains_key(key) {
|
|
350
|
+
return Err(anyhow!("Legacy section '[{key}]' is not supported."));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
validate_instance_keys(table)?;
|
|
354
|
+
Ok(())
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn validate_instance_keys(table: &toml::value::Table) -> Result<()> {
|
|
358
|
+
let Some(instances) = table.get("instances") else {
|
|
359
|
+
return Ok(());
|
|
360
|
+
};
|
|
361
|
+
let instance_entries = instances
|
|
362
|
+
.as_table()
|
|
363
|
+
.ok_or_else(|| anyhow!("Invalid .99problems: 'instances' must be a TOML table."))?;
|
|
364
|
+
|
|
365
|
+
for (alias, value) in instance_entries {
|
|
366
|
+
let cfg_table = value.as_table().ok_or_else(|| {
|
|
367
|
+
anyhow!("Invalid .99problems: instances.{alias} must be a TOML table.")
|
|
368
|
+
})?;
|
|
369
|
+
for key in cfg_table.keys() {
|
|
370
|
+
if key == "email" {
|
|
371
|
+
return Err(anyhow!(
|
|
372
|
+
"Unsupported key 'instances.{alias}.email'. Use 'instances.{alias}.account_email' instead."
|
|
373
|
+
));
|
|
374
|
+
}
|
|
375
|
+
if !matches!(
|
|
376
|
+
key.as_str(),
|
|
377
|
+
"platform"
|
|
378
|
+
| "token"
|
|
379
|
+
| "account_email"
|
|
380
|
+
| "url"
|
|
381
|
+
| "repo"
|
|
382
|
+
| "state"
|
|
383
|
+
| "type"
|
|
384
|
+
| "deployment"
|
|
385
|
+
| "per_page"
|
|
386
|
+
) {
|
|
387
|
+
return Err(anyhow!(
|
|
388
|
+
"Unsupported key 'instances.{alias}.{key}' in .99problems."
|
|
389
|
+
));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
Ok(())
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#[must_use]
|
|
398
|
+
pub fn token_env_var(platform: &str) -> &'static str {
|
|
399
|
+
match platform {
|
|
400
|
+
"github" => "GITHUB_TOKEN",
|
|
401
|
+
"gitlab" => "GITLAB_TOKEN",
|
|
402
|
+
"jira" => "JIRA_TOKEN",
|
|
403
|
+
"bitbucket" => "BITBUCKET_TOKEN",
|
|
404
|
+
_ => "TOKEN",
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#[must_use]
|
|
409
|
+
pub fn account_email_env_var(platform: &str) -> Option<&'static str> {
|
|
410
|
+
match platform {
|
|
411
|
+
"jira" => Some("JIRA_ACCOUNT_EMAIL"),
|
|
412
|
+
_ => None,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
67
416
|
#[cfg(test)]
|
|
68
417
|
mod tests {
|
|
69
418
|
use super::*;
|
|
70
419
|
|
|
420
|
+
fn resolve_with_opts(dotfile: &str, opts: ResolveOptions<'_>) -> Result<Config> {
|
|
421
|
+
let home = DotfileConfig::default();
|
|
422
|
+
let local = parse_dotfile_content(dotfile)?;
|
|
423
|
+
resolve_from_dotfiles(home, local, opts)
|
|
424
|
+
}
|
|
425
|
+
|
|
71
426
|
#[test]
|
|
72
|
-
fn
|
|
73
|
-
let cfg =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
427
|
+
fn parse_instance_only_dotfile() {
|
|
428
|
+
let cfg = parse_dotfile_content(
|
|
429
|
+
r#"
|
|
430
|
+
default_instance = "work"
|
|
431
|
+
[instances.work]
|
|
432
|
+
platform = "gitlab"
|
|
433
|
+
repo = "group/project"
|
|
434
|
+
"#,
|
|
435
|
+
)
|
|
436
|
+
.unwrap();
|
|
437
|
+
assert_eq!(cfg.default_instance.as_deref(), Some("work"));
|
|
438
|
+
assert!(cfg.instances.contains_key("work"));
|
|
77
439
|
}
|
|
78
440
|
|
|
79
441
|
#[test]
|
|
80
|
-
fn
|
|
81
|
-
let
|
|
82
|
-
|
|
442
|
+
fn rejects_legacy_section() {
|
|
443
|
+
let err = parse_dotfile_content(
|
|
444
|
+
r#"
|
|
445
|
+
[github]
|
|
446
|
+
token = "x"
|
|
447
|
+
"#,
|
|
448
|
+
)
|
|
449
|
+
.unwrap_err()
|
|
450
|
+
.to_string();
|
|
451
|
+
assert!(err.contains("Legacy section '[github]'"));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#[test]
|
|
455
|
+
fn rejects_top_level_runtime_key() {
|
|
456
|
+
let err = parse_dotfile_content(r#"platform = "github""#)
|
|
457
|
+
.unwrap_err()
|
|
458
|
+
.to_string();
|
|
459
|
+
assert!(err.contains("Unsupported top-level key 'platform'"));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#[test]
|
|
463
|
+
fn rejects_legacy_instance_email_key() {
|
|
464
|
+
let err = parse_dotfile_content(
|
|
465
|
+
r#"
|
|
466
|
+
[instances.work]
|
|
467
|
+
platform = "jira"
|
|
468
|
+
email = "user@example.com"
|
|
469
|
+
"#,
|
|
470
|
+
)
|
|
471
|
+
.unwrap_err()
|
|
472
|
+
.to_string();
|
|
473
|
+
assert!(err.contains("instances.work.email"));
|
|
474
|
+
assert!(err.contains("account_email"));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#[test]
|
|
478
|
+
fn rejects_unknown_instance_key() {
|
|
479
|
+
let err = parse_dotfile_content(
|
|
480
|
+
r#"
|
|
481
|
+
[instances.work]
|
|
482
|
+
platform = "github"
|
|
483
|
+
foo = "bar"
|
|
484
|
+
"#,
|
|
485
|
+
)
|
|
486
|
+
.unwrap_err()
|
|
487
|
+
.to_string();
|
|
488
|
+
assert!(err.contains("instances.work.foo"));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn auto_selects_single_instance() {
|
|
493
|
+
let cfg = resolve_with_opts(
|
|
494
|
+
r#"
|
|
495
|
+
[instances.only]
|
|
496
|
+
platform = "gitlab"
|
|
497
|
+
repo = "group/project"
|
|
498
|
+
"#,
|
|
499
|
+
ResolveOptions::default(),
|
|
500
|
+
)
|
|
501
|
+
.unwrap();
|
|
502
|
+
assert_eq!(cfg.platform, "gitlab");
|
|
503
|
+
assert_eq!(cfg.repo.as_deref(), Some("group/project"));
|
|
504
|
+
assert_eq!(cfg.kind, "issue");
|
|
505
|
+
assert!(!cfg.kind_explicit);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#[test]
|
|
509
|
+
fn uses_default_instance_when_multiple() {
|
|
510
|
+
let cfg = resolve_with_opts(
|
|
511
|
+
r#"
|
|
512
|
+
default_instance = "work"
|
|
513
|
+
[instances.work]
|
|
514
|
+
platform = "gitlab"
|
|
515
|
+
repo = "group/work"
|
|
516
|
+
|
|
517
|
+
[instances.public]
|
|
518
|
+
platform = "github"
|
|
83
519
|
repo = "owner/repo"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
assert_eq!(cfg.
|
|
520
|
+
"#,
|
|
521
|
+
ResolveOptions::default(),
|
|
522
|
+
)
|
|
523
|
+
.unwrap();
|
|
524
|
+
assert_eq!(cfg.platform, "gitlab");
|
|
525
|
+
assert_eq!(cfg.repo.as_deref(), Some("group/work"));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#[test]
|
|
529
|
+
fn errors_on_ambiguous_instances_without_default() {
|
|
530
|
+
let err = resolve_with_opts(
|
|
531
|
+
r#"
|
|
532
|
+
[instances.work]
|
|
533
|
+
platform = "gitlab"
|
|
534
|
+
|
|
535
|
+
[instances.public]
|
|
536
|
+
platform = "github"
|
|
537
|
+
"#,
|
|
538
|
+
ResolveOptions::default(),
|
|
539
|
+
)
|
|
540
|
+
.unwrap_err()
|
|
541
|
+
.to_string();
|
|
542
|
+
assert!(err.contains("Multiple instances configured"));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#[test]
|
|
546
|
+
fn errors_on_unknown_instance() {
|
|
547
|
+
let err = resolve_with_opts(
|
|
548
|
+
r#"
|
|
549
|
+
[instances.work]
|
|
550
|
+
platform = "gitlab"
|
|
551
|
+
"#,
|
|
552
|
+
ResolveOptions {
|
|
553
|
+
instance: Some("missing"),
|
|
554
|
+
..ResolveOptions::default()
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
.unwrap_err()
|
|
558
|
+
.to_string();
|
|
559
|
+
assert!(err.contains("Instance 'missing' not found"));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
#[test]
|
|
563
|
+
fn errors_on_platform_mismatch() {
|
|
564
|
+
let err = resolve_with_opts(
|
|
565
|
+
r#"
|
|
566
|
+
[instances.work]
|
|
567
|
+
platform = "gitlab"
|
|
568
|
+
"#,
|
|
569
|
+
ResolveOptions {
|
|
570
|
+
instance: Some("work"),
|
|
571
|
+
platform: Some("github"),
|
|
572
|
+
..ResolveOptions::default()
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
.unwrap_err()
|
|
576
|
+
.to_string();
|
|
577
|
+
assert!(err.contains("Platform mismatch"));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#[test]
|
|
581
|
+
fn cli_overrides_instance_fields() {
|
|
582
|
+
let cfg = resolve_with_opts(
|
|
583
|
+
r#"
|
|
584
|
+
[instances.work]
|
|
585
|
+
platform = "gitlab"
|
|
586
|
+
repo = "group/project"
|
|
587
|
+
state = "opened"
|
|
588
|
+
type = "issue"
|
|
589
|
+
"#,
|
|
590
|
+
ResolveOptions {
|
|
591
|
+
repo: Some("override/repo"),
|
|
592
|
+
state: Some("closed"),
|
|
593
|
+
kind: Some("pr"),
|
|
594
|
+
..ResolveOptions::default()
|
|
595
|
+
},
|
|
596
|
+
)
|
|
597
|
+
.unwrap();
|
|
598
|
+
assert_eq!(cfg.repo.as_deref(), Some("override/repo"));
|
|
599
|
+
assert_eq!(cfg.state.as_deref(), Some("closed"));
|
|
600
|
+
assert_eq!(cfg.kind, "pr");
|
|
601
|
+
assert!(cfg.kind_explicit);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#[test]
|
|
605
|
+
fn cli_only_mode_without_instances_works() {
|
|
606
|
+
let cfg = resolve_from_dotfiles(
|
|
607
|
+
DotfileConfig::default(),
|
|
608
|
+
DotfileConfig::default(),
|
|
609
|
+
ResolveOptions {
|
|
610
|
+
platform: Some("github"),
|
|
611
|
+
repo: Some("owner/repo"),
|
|
612
|
+
..ResolveOptions::default()
|
|
613
|
+
},
|
|
614
|
+
)
|
|
615
|
+
.unwrap();
|
|
616
|
+
assert_eq!(cfg.platform, "github");
|
|
89
617
|
assert_eq!(cfg.repo.as_deref(), Some("owner/repo"));
|
|
90
|
-
assert_eq!(cfg.
|
|
618
|
+
assert_eq!(cfg.kind, "issue");
|
|
619
|
+
assert!(!cfg.kind_explicit);
|
|
91
620
|
}
|
|
92
621
|
|
|
93
622
|
#[test]
|
|
94
|
-
fn
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
623
|
+
fn merge_instances_deep_merges_fields() {
|
|
624
|
+
let home = DotfileConfig {
|
|
625
|
+
default_instance: None,
|
|
626
|
+
instances: HashMap::from([(
|
|
627
|
+
"work".to_string(),
|
|
628
|
+
InstanceConfig {
|
|
629
|
+
platform: Some("gitlab".to_string()),
|
|
630
|
+
token: Some("home-token".to_string()),
|
|
631
|
+
account_email: None,
|
|
632
|
+
url: Some("https://home.example".to_string()),
|
|
633
|
+
repo: Some("group/home".to_string()),
|
|
634
|
+
state: None,
|
|
635
|
+
kind: None,
|
|
636
|
+
deployment: None,
|
|
637
|
+
per_page: Some(20),
|
|
638
|
+
},
|
|
639
|
+
)]),
|
|
640
|
+
};
|
|
641
|
+
let local = DotfileConfig {
|
|
642
|
+
default_instance: Some("work".to_string()),
|
|
643
|
+
instances: HashMap::from([(
|
|
644
|
+
"work".to_string(),
|
|
645
|
+
InstanceConfig {
|
|
646
|
+
platform: None,
|
|
647
|
+
token: Some("local-token".to_string()),
|
|
648
|
+
account_email: None,
|
|
649
|
+
url: None,
|
|
650
|
+
repo: Some("group/local".to_string()),
|
|
651
|
+
state: Some("opened".to_string()),
|
|
652
|
+
kind: Some("pr".to_string()),
|
|
653
|
+
deployment: None,
|
|
654
|
+
per_page: None,
|
|
655
|
+
},
|
|
656
|
+
)]),
|
|
657
|
+
};
|
|
658
|
+
let merged = merge_dotfiles(home, local);
|
|
659
|
+
let work = merged.instances.get("work").unwrap();
|
|
660
|
+
assert_eq!(work.platform.as_deref(), Some("gitlab"));
|
|
661
|
+
assert_eq!(work.token.as_deref(), Some("local-token"));
|
|
662
|
+
assert_eq!(work.url.as_deref(), Some("https://home.example"));
|
|
663
|
+
assert_eq!(work.repo.as_deref(), Some("group/local"));
|
|
664
|
+
assert_eq!(work.state.as_deref(), Some("opened"));
|
|
665
|
+
assert_eq!(work.kind.as_deref(), Some("pr"));
|
|
666
|
+
assert_eq!(work.per_page, Some(20));
|
|
667
|
+
assert_eq!(merged.default_instance.as_deref(), Some("work"));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[test]
|
|
671
|
+
fn bitbucket_requires_deployment() {
|
|
672
|
+
let err = resolve_with_opts(
|
|
673
|
+
r#"
|
|
674
|
+
[instances.work]
|
|
675
|
+
platform = "bitbucket"
|
|
676
|
+
repo = "workspace/repo"
|
|
677
|
+
"#,
|
|
678
|
+
ResolveOptions::default(),
|
|
679
|
+
)
|
|
680
|
+
.unwrap_err()
|
|
681
|
+
.to_string();
|
|
682
|
+
assert!(err.contains("Bitbucket deployment is required"));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
#[test]
|
|
686
|
+
fn deployment_rejected_for_non_bitbucket_platforms() {
|
|
687
|
+
let err = resolve_from_dotfiles(
|
|
688
|
+
DotfileConfig::default(),
|
|
689
|
+
DotfileConfig::default(),
|
|
690
|
+
ResolveOptions {
|
|
691
|
+
platform: Some("github"),
|
|
692
|
+
deployment: Some("cloud"),
|
|
693
|
+
..ResolveOptions::default()
|
|
694
|
+
},
|
|
695
|
+
)
|
|
696
|
+
.unwrap_err()
|
|
697
|
+
.to_string();
|
|
698
|
+
assert!(err.contains("Deployment is only supported"));
|
|
100
699
|
}
|
|
101
700
|
}
|