@mbe24/99problems 0.2.0 → 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 +150 -107
- package/Cargo.toml +7 -2
- package/README.md +107 -85
- 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 +618 -118
- 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 +225 -138
- 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 +65 -7
- package/tests/integration.rs +404 -33
- package/src/source/github_issues.rs +0 -227
package/src/config.rs
CHANGED
|
@@ -1,119 +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
|
-
/// Per-platform credentials and settings.
|
|
6
6
|
#[derive(Debug, Default, Deserialize, Clone)]
|
|
7
|
-
pub struct
|
|
7
|
+
pub struct InstanceConfig {
|
|
8
|
+
pub platform: Option<String>,
|
|
8
9
|
pub token: Option<String>,
|
|
9
|
-
|
|
10
|
+
pub account_email: Option<String>,
|
|
10
11
|
pub url: Option<String>,
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/// Top-level dotfile structure (.99problems).
|
|
14
|
-
#[derive(Debug, Default, Deserialize)]
|
|
15
|
-
pub struct DotfileConfig {
|
|
16
|
-
/// Default platform (overridden by --platform flag)
|
|
17
|
-
pub platform: Option<String>,
|
|
18
|
-
/// Default type: "issue" or "pr" (overridden by --type flag)
|
|
19
|
-
#[serde(rename = "type")]
|
|
20
|
-
pub kind: Option<String>,
|
|
21
12
|
pub repo: Option<String>,
|
|
22
13
|
pub state: Option<String>,
|
|
14
|
+
#[serde(rename = "type")]
|
|
15
|
+
pub kind: Option<String>,
|
|
16
|
+
pub deployment: Option<String>,
|
|
23
17
|
pub per_page: Option<u32>,
|
|
24
|
-
pub github: Option<PlatformConfig>,
|
|
25
|
-
pub gitlab: Option<PlatformConfig>,
|
|
26
|
-
pub bitbucket: Option<PlatformConfig>,
|
|
27
18
|
}
|
|
28
19
|
|
|
29
|
-
|
|
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
|
+
|
|
30
27
|
#[derive(Debug, Default)]
|
|
31
28
|
pub struct Config {
|
|
32
29
|
pub platform: String,
|
|
33
30
|
pub kind: String,
|
|
31
|
+
pub kind_explicit: bool,
|
|
34
32
|
pub token: Option<String>,
|
|
33
|
+
pub account_email: Option<String>,
|
|
35
34
|
pub repo: Option<String>,
|
|
36
35
|
pub state: Option<String>,
|
|
36
|
+
pub deployment: Option<String>,
|
|
37
37
|
pub per_page: u32,
|
|
38
|
-
/// Base URL for the active platform (for self-hosted instances)
|
|
39
|
-
#[allow(dead_code)]
|
|
40
38
|
pub platform_url: Option<String>,
|
|
41
39
|
}
|
|
42
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>,
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
impl Config {
|
|
44
|
-
/// Load and
|
|
45
|
-
///
|
|
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.
|
|
46
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> {
|
|
47
72
|
let home = load_dotfile(home_dotfile_path())?;
|
|
48
73
|
let local = load_dotfile(local_dotfile_path())?;
|
|
74
|
+
resolve_from_dotfiles(home, local, opts)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
49
77
|
|
|
50
|
-
|
|
78
|
+
#[must_use]
|
|
79
|
+
fn merge_instance(base: &InstanceConfig, override_cfg: &InstanceConfig) -> InstanceConfig {
|
|
80
|
+
InstanceConfig {
|
|
81
|
+
platform: override_cfg
|
|
51
82
|
.platform
|
|
52
83
|
.clone()
|
|
53
|
-
.or_else(||
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.kind
|
|
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
|
|
58
88
|
.clone()
|
|
59
|
-
.or_else(||
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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);
|
|
75
124
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
kind,
|
|
79
|
-
token,
|
|
80
|
-
repo,
|
|
81
|
-
state,
|
|
82
|
-
per_page,
|
|
83
|
-
platform_url,
|
|
84
|
-
})
|
|
125
|
+
if !merged.instances.is_empty() {
|
|
126
|
+
return resolve_instance_mode(&merged, opts);
|
|
85
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)
|
|
86
140
|
}
|
|
87
141
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
150
|
|
|
97
|
-
let
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
};
|
|
101
175
|
|
|
102
|
-
let
|
|
103
|
-
.
|
|
104
|
-
.
|
|
105
|
-
.
|
|
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
|
+
})?;
|
|
106
183
|
|
|
107
|
-
(
|
|
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
|
+
};
|
|
214
|
+
|
|
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
|
+
})
|
|
108
236
|
}
|
|
109
237
|
|
|
110
|
-
fn
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
|
|
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."));
|
|
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
|
+
));
|
|
116
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(", ")
|
|
117
305
|
}
|
|
118
306
|
|
|
119
307
|
fn home_dotfile_path() -> Option<PathBuf> {
|
|
@@ -130,71 +318,383 @@ fn load_dotfile(path: Option<PathBuf>) -> Result<DotfileConfig> {
|
|
|
130
318
|
_ => return Ok(DotfileConfig::default()),
|
|
131
319
|
};
|
|
132
320
|
let content = std::fs::read_to_string(&path)?;
|
|
133
|
-
|
|
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)?;
|
|
134
331
|
Ok(cfg)
|
|
135
332
|
}
|
|
136
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
|
+
|
|
137
416
|
#[cfg(test)]
|
|
138
417
|
mod tests {
|
|
139
418
|
use super::*;
|
|
140
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
|
+
|
|
426
|
+
#[test]
|
|
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"));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[test]
|
|
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
|
+
|
|
141
462
|
#[test]
|
|
142
|
-
fn
|
|
143
|
-
let
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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"));
|
|
147
475
|
}
|
|
148
476
|
|
|
149
477
|
#[test]
|
|
150
|
-
fn
|
|
151
|
-
let
|
|
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"
|
|
152
519
|
repo = "owner/repo"
|
|
153
|
-
|
|
154
|
-
|
|
520
|
+
"#,
|
|
521
|
+
ResolveOptions::default(),
|
|
522
|
+
)
|
|
523
|
+
.unwrap();
|
|
524
|
+
assert_eq!(cfg.platform, "gitlab");
|
|
525
|
+
assert_eq!(cfg.repo.as_deref(), Some("group/work"));
|
|
526
|
+
}
|
|
155
527
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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"));
|
|
166
543
|
}
|
|
167
544
|
|
|
168
545
|
#[test]
|
|
169
|
-
fn
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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"));
|
|
174
560
|
}
|
|
175
561
|
|
|
176
562
|
#[test]
|
|
177
|
-
fn
|
|
178
|
-
let
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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"));
|
|
185
578
|
}
|
|
186
579
|
|
|
187
580
|
#[test]
|
|
188
|
-
fn
|
|
189
|
-
let
|
|
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");
|
|
617
|
+
assert_eq!(cfg.repo.as_deref(), Some("owner/repo"));
|
|
618
|
+
assert_eq!(cfg.kind, "issue");
|
|
619
|
+
assert!(!cfg.kind_explicit);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
#[test]
|
|
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
|
+
};
|
|
190
641
|
let local = DotfileConfig {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
)]),
|
|
196
657
|
};
|
|
197
|
-
let
|
|
198
|
-
|
|
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"));
|
|
199
699
|
}
|
|
200
700
|
}
|