@mbe24/99problems 0.2.0 → 0.3.1

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.
Files changed (72) hide show
  1. package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
  2. package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
  3. package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  5. package/.github/dependabot.yml +20 -0
  6. package/.github/scripts/publish.js +1 -1
  7. package/.github/workflows/ci.yml +32 -6
  8. package/.github/workflows/man-drift.yml +26 -0
  9. package/.github/workflows/release.yml +49 -9
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +107 -85
  14. package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
  15. package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
  16. package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
  17. package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
  18. package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
  19. package/docs/man/99problems-completions.1 +31 -0
  20. package/docs/man/99problems-config.1 +50 -0
  21. package/docs/man/99problems-get.1 +114 -0
  22. package/docs/man/99problems-help.1 +25 -0
  23. package/docs/man/99problems-man.1 +32 -0
  24. package/docs/man/99problems.1 +52 -0
  25. package/npm/install.js +90 -3
  26. package/package.json +7 -7
  27. package/rust-toolchain.toml +4 -0
  28. package/src/cmd/config/key.rs +126 -0
  29. package/src/cmd/config/mod.rs +218 -0
  30. package/src/cmd/config/render.rs +33 -0
  31. package/src/cmd/config/store.rs +318 -0
  32. package/src/cmd/config/write.rs +173 -0
  33. package/src/cmd/get.rs +658 -0
  34. package/src/cmd/man.rs +117 -0
  35. package/src/cmd/mod.rs +3 -0
  36. package/src/config.rs +618 -118
  37. package/src/error.rs +254 -0
  38. package/src/format/json.rs +59 -18
  39. package/src/format/jsonl.rs +52 -0
  40. package/src/format/mod.rs +25 -3
  41. package/src/format/text.rs +73 -0
  42. package/src/format/yaml.rs +64 -15
  43. package/src/lib.rs +1 -0
  44. package/src/logging.rs +54 -0
  45. package/src/main.rs +225 -138
  46. package/src/model.rs +9 -1
  47. package/src/source/bitbucket/cloud/api.rs +67 -0
  48. package/src/source/bitbucket/cloud/mod.rs +178 -0
  49. package/src/source/bitbucket/cloud/model.rs +211 -0
  50. package/src/source/bitbucket/datacenter/api.rs +74 -0
  51. package/src/source/bitbucket/datacenter/mod.rs +181 -0
  52. package/src/source/bitbucket/datacenter/model.rs +327 -0
  53. package/src/source/bitbucket/mod.rs +90 -0
  54. package/src/source/bitbucket/query.rs +169 -0
  55. package/src/source/bitbucket/shared/auth.rs +54 -0
  56. package/src/source/bitbucket/shared/http.rs +59 -0
  57. package/src/source/bitbucket/shared/mod.rs +5 -0
  58. package/src/source/github/api.rs +128 -0
  59. package/src/source/github/mod.rs +191 -0
  60. package/src/source/github/model.rs +84 -0
  61. package/src/source/github/query.rs +50 -0
  62. package/src/source/gitlab/api.rs +282 -0
  63. package/src/source/gitlab/mod.rs +225 -0
  64. package/src/source/gitlab/model.rs +102 -0
  65. package/src/source/gitlab/query.rs +177 -0
  66. package/src/source/jira/api.rs +222 -0
  67. package/src/source/jira/mod.rs +161 -0
  68. package/src/source/jira/model.rs +99 -0
  69. package/src/source/jira/query.rs +153 -0
  70. package/src/source/mod.rs +65 -7
  71. package/tests/integration.rs +404 -33
  72. package/src/source/github_issues.rs +0 -227
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbe24/99problems",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "GitHub issue conversation fetcher CLI",
5
5
  "bin": {
6
6
  "99problems": "./npm/bin/99problems.js"
@@ -9,13 +9,13 @@
9
9
  "postinstall": "node npm/install.js"
10
10
  },
11
11
  "optionalDependencies": {
12
- "@mbe24/99problems-win32-x64": "0.2.0",
13
- "@mbe24/99problems-linux-x64": "0.2.0",
14
- "@mbe24/99problems-darwin-x64": "0.2.0",
15
- "@mbe24/99problems-darwin-arm64": "0.2.0",
16
- "@mbe24/99problems-linux-arm64": "0.2.0"
12
+ "@mbe24/99problems-win32-x64": "0.3.1",
13
+ "@mbe24/99problems-linux-x64": "0.3.1",
14
+ "@mbe24/99problems-darwin-x64": "0.3.1",
15
+ "@mbe24/99problems-darwin-arm64": "0.3.1",
16
+ "@mbe24/99problems-linux-arm64": "0.3.1"
17
17
  },
18
- "license": "MIT",
18
+ "license": "Apache-2.0",
19
19
  "repository": {
20
20
  "type": "git",
21
21
  "url": "https://github.com/mbe24/99problems"
@@ -0,0 +1,4 @@
1
+ [toolchain]
2
+ channel = "stable"
3
+ profile = "minimal"
4
+ components = ["clippy", "rustfmt"]
@@ -0,0 +1,126 @@
1
+ use anyhow::{Result, anyhow};
2
+
3
+ #[derive(Debug, Clone, PartialEq, Eq)]
4
+ pub(crate) enum ConfigKey {
5
+ DefaultInstance,
6
+ InstanceField { alias: String, field: InstanceField },
7
+ }
8
+
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
+ pub(crate) enum InstanceField {
11
+ Platform,
12
+ Url,
13
+ Token,
14
+ AccountEmail,
15
+ Repo,
16
+ State,
17
+ Type,
18
+ Deployment,
19
+ PerPage,
20
+ }
21
+
22
+ impl InstanceField {
23
+ pub(crate) fn as_str(self) -> &'static str {
24
+ match self {
25
+ InstanceField::Platform => "platform",
26
+ InstanceField::Url => "url",
27
+ InstanceField::Token => "token",
28
+ InstanceField::AccountEmail => "account_email",
29
+ InstanceField::Repo => "repo",
30
+ InstanceField::State => "state",
31
+ InstanceField::Type => "type",
32
+ InstanceField::Deployment => "deployment",
33
+ InstanceField::PerPage => "per_page",
34
+ }
35
+ }
36
+ }
37
+
38
+ impl ConfigKey {
39
+ /// Parse a key path used by `config get/set/unset`.
40
+ ///
41
+ /// # Errors
42
+ ///
43
+ /// Returns an error when the key path is malformed or unsupported.
44
+ pub(crate) fn parse(raw: &str) -> Result<Self> {
45
+ let trimmed = raw.trim();
46
+ if trimmed == "default_instance" {
47
+ return Ok(Self::DefaultInstance);
48
+ }
49
+
50
+ let parts: Vec<&str> = trimmed.split('.').collect();
51
+ if parts.len() != 3 || parts.first() != Some(&"instances") {
52
+ return Err(anyhow!(
53
+ "Invalid key path '{raw}'. Use 'default_instance' or 'instances.<alias>.<field>'."
54
+ ));
55
+ }
56
+ let alias = parts[1].trim();
57
+ if alias.is_empty() {
58
+ return Err(anyhow!(
59
+ "Invalid key path '{raw}': instance alias cannot be empty."
60
+ ));
61
+ }
62
+
63
+ let field = match parts[2] {
64
+ "platform" => InstanceField::Platform,
65
+ "url" => InstanceField::Url,
66
+ "token" => InstanceField::Token,
67
+ "account_email" => InstanceField::AccountEmail,
68
+ "repo" => InstanceField::Repo,
69
+ "state" => InstanceField::State,
70
+ "type" => InstanceField::Type,
71
+ "deployment" => InstanceField::Deployment,
72
+ "per_page" => InstanceField::PerPage,
73
+ other => {
74
+ return Err(anyhow!(
75
+ "Unsupported instance field '{other}'. Supported: platform, url, token, account_email, repo, state, type, deployment, per_page."
76
+ ));
77
+ }
78
+ };
79
+
80
+ Ok(Self::InstanceField {
81
+ alias: alias.to_string(),
82
+ field,
83
+ })
84
+ }
85
+
86
+ pub(crate) fn is_secret(&self) -> bool {
87
+ matches!(
88
+ self,
89
+ Self::InstanceField {
90
+ field: InstanceField::Token,
91
+ ..
92
+ }
93
+ )
94
+ }
95
+ }
96
+
97
+ #[cfg(test)]
98
+ mod tests {
99
+ use super::*;
100
+
101
+ #[test]
102
+ fn parses_default_instance() {
103
+ assert_eq!(
104
+ ConfigKey::parse("default_instance").unwrap(),
105
+ ConfigKey::DefaultInstance
106
+ );
107
+ }
108
+
109
+ #[test]
110
+ fn parses_instance_field() {
111
+ let key = ConfigKey::parse("instances.work.platform").unwrap();
112
+ assert_eq!(
113
+ key,
114
+ ConfigKey::InstanceField {
115
+ alias: "work".to_string(),
116
+ field: InstanceField::Platform
117
+ }
118
+ );
119
+ }
120
+
121
+ #[test]
122
+ fn rejects_invalid_path() {
123
+ let err = ConfigKey::parse("instances.work").unwrap_err().to_string();
124
+ assert!(err.contains("Invalid key path"));
125
+ }
126
+ }
@@ -0,0 +1,218 @@
1
+ mod key;
2
+ mod render;
3
+ mod store;
4
+ mod write;
5
+
6
+ use anyhow::{Result, anyhow};
7
+ use clap::{Args, Subcommand, ValueEnum};
8
+
9
+ use key::ConfigKey;
10
+ use render::render_value;
11
+ use store::{ReadScope, WriteScope};
12
+
13
+ #[derive(Args, Debug)]
14
+ #[command(
15
+ next_line_help = true,
16
+ after_help = "Examples:\n 99problems config list\n 99problems config set default_instance work-gitlab\n 99problems config get instances.work-gitlab.repo --scope resolved"
17
+ )]
18
+ pub(crate) struct ConfigArgs {
19
+ #[command(subcommand)]
20
+ pub(crate) command: ConfigSubcommand,
21
+ }
22
+
23
+ #[derive(Subcommand, Debug)]
24
+ pub(crate) enum ConfigSubcommand {
25
+ /// Print config file path for a scope
26
+ Path {
27
+ #[arg(long, value_enum, default_value = "local")]
28
+ scope: PathScope,
29
+ },
30
+ /// List all configured keys in a scope
31
+ List {
32
+ #[arg(long, value_enum, default_value = "resolved")]
33
+ scope: ReadScopeArg,
34
+ #[arg(long)]
35
+ show_secrets: bool,
36
+ },
37
+ /// Read one configured key
38
+ Get {
39
+ key: String,
40
+ #[arg(long, value_enum, default_value = "resolved")]
41
+ scope: ReadScopeArg,
42
+ #[arg(long)]
43
+ show_secrets: bool,
44
+ },
45
+ /// Set a configured key
46
+ Set {
47
+ key: String,
48
+ value: String,
49
+ #[arg(long, value_enum, default_value = "local")]
50
+ scope: WriteScopeArg,
51
+ },
52
+ /// Unset a configured key
53
+ Unset {
54
+ key: String,
55
+ #[arg(long, value_enum, default_value = "local")]
56
+ scope: WriteScopeArg,
57
+ },
58
+ }
59
+
60
+ #[derive(Debug, Clone, Copy, ValueEnum)]
61
+ pub(crate) enum PathScope {
62
+ Home,
63
+ Local,
64
+ }
65
+
66
+ #[derive(Debug, Clone, Copy, ValueEnum)]
67
+ pub(crate) enum ReadScopeArg {
68
+ Home,
69
+ Local,
70
+ Resolved,
71
+ }
72
+
73
+ #[derive(Debug, Clone, Copy, ValueEnum)]
74
+ pub(crate) enum WriteScopeArg {
75
+ Home,
76
+ Local,
77
+ }
78
+
79
+ impl From<PathScope> for ReadScope {
80
+ fn from(value: PathScope) -> Self {
81
+ match value {
82
+ PathScope::Home => Self::Home,
83
+ PathScope::Local => Self::Local,
84
+ }
85
+ }
86
+ }
87
+
88
+ impl From<ReadScopeArg> for ReadScope {
89
+ fn from(value: ReadScopeArg) -> Self {
90
+ match value {
91
+ ReadScopeArg::Home => Self::Home,
92
+ ReadScopeArg::Local => Self::Local,
93
+ ReadScopeArg::Resolved => Self::Resolved,
94
+ }
95
+ }
96
+ }
97
+
98
+ impl From<WriteScopeArg> for WriteScope {
99
+ fn from(value: WriteScopeArg) -> Self {
100
+ match value {
101
+ WriteScopeArg::Home => Self::Home,
102
+ WriteScopeArg::Local => Self::Local,
103
+ }
104
+ }
105
+ }
106
+
107
+ /// Run the `config` command family.
108
+ ///
109
+ /// # Errors
110
+ ///
111
+ /// Returns an error if scope loading fails, key parsing/validation fails,
112
+ /// or set/unset operations fail to persist.
113
+ pub(crate) fn run(args: &ConfigArgs) -> Result<()> {
114
+ match &args.command {
115
+ ConfigSubcommand::Path { scope } => run_path((*scope).into()),
116
+ ConfigSubcommand::List {
117
+ scope,
118
+ show_secrets,
119
+ } => run_list((*scope).into(), *show_secrets),
120
+ ConfigSubcommand::Get {
121
+ key,
122
+ scope,
123
+ show_secrets,
124
+ } => run_get(key, (*scope).into(), *show_secrets),
125
+ ConfigSubcommand::Set { key, value, scope } => run_set(key, value, (*scope).into()),
126
+ ConfigSubcommand::Unset { key, scope } => run_unset(key, (*scope).into()),
127
+ }
128
+ }
129
+
130
+ fn run_path(scope: ReadScope) -> Result<()> {
131
+ let path = store::path_for_read_scope(scope)?;
132
+ println!("{}", path.display());
133
+ Ok(())
134
+ }
135
+
136
+ fn run_list(scope: ReadScope, show_secrets: bool) -> Result<()> {
137
+ let cfg = store::load_dotfile_scope(scope)?;
138
+ let entries = store::list_entries(&cfg);
139
+ for (key, value, is_secret) in entries {
140
+ println!("{key}={}", render_value(&value, is_secret, show_secrets));
141
+ }
142
+ Ok(())
143
+ }
144
+
145
+ fn run_get(key_raw: &str, scope: ReadScope, show_secrets: bool) -> Result<()> {
146
+ let key = ConfigKey::parse(key_raw)?;
147
+ let cfg = store::load_dotfile_scope(scope)?;
148
+ let value = store::get_key_value(&cfg, &key).ok_or_else(|| {
149
+ anyhow!(
150
+ "Key '{key_raw}' is not set for scope '{}'.",
151
+ scope_name(scope)
152
+ )
153
+ })?;
154
+
155
+ println!(
156
+ "{key_raw}={}",
157
+ render_value(&value, key.is_secret(), show_secrets)
158
+ );
159
+ Ok(())
160
+ }
161
+
162
+ fn run_set(key_raw: &str, value: &str, scope: WriteScope) -> Result<()> {
163
+ let key = ConfigKey::parse(key_raw)?;
164
+ write::set(scope, &key, value)?;
165
+ let display_value = if key.is_secret() {
166
+ "****".to_string()
167
+ } else {
168
+ value.to_string()
169
+ };
170
+ println!(
171
+ "Set {key_raw}={display_value} in {} scope.",
172
+ scope_name_write(scope)
173
+ );
174
+ Ok(())
175
+ }
176
+
177
+ fn run_unset(key_raw: &str, scope: WriteScope) -> Result<()> {
178
+ let key = ConfigKey::parse(key_raw)?;
179
+ write::unset(scope, &key)?;
180
+ println!("Unset {key_raw} in {} scope.", scope_name_write(scope));
181
+ Ok(())
182
+ }
183
+
184
+ fn scope_name(scope: ReadScope) -> &'static str {
185
+ match scope {
186
+ ReadScope::Home => "home",
187
+ ReadScope::Local => "local",
188
+ ReadScope::Resolved => "resolved",
189
+ }
190
+ }
191
+
192
+ fn scope_name_write(scope: WriteScope) -> &'static str {
193
+ match scope {
194
+ WriteScope::Home => "home",
195
+ WriteScope::Local => "local",
196
+ }
197
+ }
198
+
199
+ #[cfg(test)]
200
+ mod tests {
201
+ use super::*;
202
+ use crate::cmd::config::key::InstanceField;
203
+
204
+ #[test]
205
+ fn parse_key_paths() {
206
+ assert!(matches!(
207
+ ConfigKey::parse("default_instance").unwrap(),
208
+ ConfigKey::DefaultInstance
209
+ ));
210
+ assert!(matches!(
211
+ ConfigKey::parse("instances.work.platform").unwrap(),
212
+ ConfigKey::InstanceField {
213
+ alias,
214
+ field: InstanceField::Platform
215
+ } if alias == "work"
216
+ ));
217
+ }
218
+ }
@@ -0,0 +1,33 @@
1
+ pub(crate) fn mask_secret(value: &str) -> String {
2
+ if value.is_empty() {
3
+ return "****".to_string();
4
+ }
5
+ for prefix in ["github_pat_", "glpat_", "ghp_", "AT"] {
6
+ if value.starts_with(prefix) {
7
+ return format!("{prefix}****");
8
+ }
9
+ }
10
+ if value.len() <= 4 {
11
+ return "****".to_string();
12
+ }
13
+ format!("{}****", &value[..4])
14
+ }
15
+
16
+ pub(crate) fn render_value(value: &str, is_secret: bool, show_secrets: bool) -> String {
17
+ if is_secret && !show_secrets {
18
+ mask_secret(value)
19
+ } else {
20
+ value.to_string()
21
+ }
22
+ }
23
+
24
+ #[cfg(test)]
25
+ mod tests {
26
+ use super::*;
27
+
28
+ #[test]
29
+ fn masks_prefix_tokens() {
30
+ assert_eq!(mask_secret("glpat_abc123"), "glpat_****");
31
+ assert_eq!(mask_secret("ATATT3xFfGF05..."), "AT****");
32
+ }
33
+ }