@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/cmd/get.rs
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use clap::{Args, ValueEnum};
|
|
3
|
+
use std::io::{IsTerminal, Write};
|
|
4
|
+
use tracing::{debug, info, warn};
|
|
5
|
+
|
|
6
|
+
use crate::config::{Config, ResolveOptions, token_env_var};
|
|
7
|
+
use crate::error::AppError;
|
|
8
|
+
use crate::format::{
|
|
9
|
+
StreamFormatter, json::JsonStreamFormatter, jsonl::JsonLinesFormatter, text::TextFormatter,
|
|
10
|
+
yaml::YamlStreamFormatter,
|
|
11
|
+
};
|
|
12
|
+
use crate::source::{
|
|
13
|
+
ContentKind, FetchRequest, FetchTarget, Query, Source, bitbucket::BitbucketSource,
|
|
14
|
+
github::GitHubSource, gitlab::GitLabSource, jira::JiraSource,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
18
|
+
pub(crate) enum OutputFormat {
|
|
19
|
+
Json,
|
|
20
|
+
Yaml,
|
|
21
|
+
Jsonl,
|
|
22
|
+
Ndjson,
|
|
23
|
+
Text,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
|
27
|
+
pub(crate) enum OutputMode {
|
|
28
|
+
Auto,
|
|
29
|
+
Batch,
|
|
30
|
+
Stream,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Clone, ValueEnum, PartialEq)]
|
|
34
|
+
pub(crate) enum Platform {
|
|
35
|
+
Github,
|
|
36
|
+
Gitlab,
|
|
37
|
+
Jira,
|
|
38
|
+
Bitbucket,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl Platform {
|
|
42
|
+
pub(crate) fn as_str(&self) -> &str {
|
|
43
|
+
match self {
|
|
44
|
+
Platform::Github => "github",
|
|
45
|
+
Platform::Gitlab => "gitlab",
|
|
46
|
+
Platform::Jira => "jira",
|
|
47
|
+
Platform::Bitbucket => "bitbucket",
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[derive(Debug, Clone, ValueEnum)]
|
|
53
|
+
pub(crate) enum ContentType {
|
|
54
|
+
Issue,
|
|
55
|
+
Pr,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl ContentType {
|
|
59
|
+
fn as_str(&self) -> &str {
|
|
60
|
+
match self {
|
|
61
|
+
ContentType::Issue => "issue",
|
|
62
|
+
ContentType::Pr => "pr",
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[derive(Debug, Clone, ValueEnum)]
|
|
68
|
+
pub(crate) enum DeploymentType {
|
|
69
|
+
Cloud,
|
|
70
|
+
Selfhosted,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
impl DeploymentType {
|
|
74
|
+
fn as_str(&self) -> &str {
|
|
75
|
+
match self {
|
|
76
|
+
DeploymentType::Cloud => "cloud",
|
|
77
|
+
DeploymentType::Selfhosted => "selfhosted",
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[derive(Debug, Clone, Copy)]
|
|
83
|
+
enum ResolvedOutputMode {
|
|
84
|
+
Batch,
|
|
85
|
+
Stream,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[derive(Debug, Clone, Copy)]
|
|
89
|
+
enum ResolvedOutputFormat {
|
|
90
|
+
Json,
|
|
91
|
+
Yaml,
|
|
92
|
+
Jsonl,
|
|
93
|
+
Text,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[derive(Debug, Clone, Copy)]
|
|
97
|
+
struct OutputPlan {
|
|
98
|
+
mode: ResolvedOutputMode,
|
|
99
|
+
format: ResolvedOutputFormat,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[derive(Args, Debug)]
|
|
103
|
+
#[command(
|
|
104
|
+
next_line_help = true,
|
|
105
|
+
after_help = "Examples:\n 99problems get --repo schemaorg/schemaorg --id 1842\n 99problems get --repo github/gitignore --id 2402 --type pr --include-review-comments\n 99problems get -q \"repo:owner/repo state:open label:bug\" --output-mode stream --format jsonl"
|
|
106
|
+
)]
|
|
107
|
+
pub(crate) struct GetArgs {
|
|
108
|
+
/// Full search query (same syntax as the platform's web UI search bar)
|
|
109
|
+
/// e.g. "state:closed Event repo:owner/repo"
|
|
110
|
+
#[arg(short = 'q', long)]
|
|
111
|
+
pub(crate) query: Option<String>,
|
|
112
|
+
|
|
113
|
+
/// Shorthand for adding "repo:owner/repo" to the query (alias: --project)
|
|
114
|
+
#[arg(short = 'r', long, visible_alias = "project")]
|
|
115
|
+
pub(crate) repo: Option<String>,
|
|
116
|
+
|
|
117
|
+
/// Shorthand for adding "state:open|closed" to the query
|
|
118
|
+
#[arg(short = 's', long)]
|
|
119
|
+
pub(crate) state: Option<String>,
|
|
120
|
+
|
|
121
|
+
/// Shorthand for comma-separated labels, e.g. "bug,enhancement"
|
|
122
|
+
#[arg(short = 'l', long)]
|
|
123
|
+
pub(crate) labels: Option<String>,
|
|
124
|
+
|
|
125
|
+
/// Filter by issue/PR author
|
|
126
|
+
#[arg(short = 'a', long)]
|
|
127
|
+
pub(crate) author: Option<String>,
|
|
128
|
+
|
|
129
|
+
/// Only include items created on or after this date (YYYY-MM-DD), e.g. "2024-01-01"
|
|
130
|
+
#[arg(short = 'S', long)]
|
|
131
|
+
pub(crate) since: Option<String>,
|
|
132
|
+
|
|
133
|
+
/// Filter by milestone title or number
|
|
134
|
+
#[arg(short = 'm', long)]
|
|
135
|
+
pub(crate) milestone: Option<String>,
|
|
136
|
+
|
|
137
|
+
/// Fetch a single issue/PR by identifier (bypasses search)
|
|
138
|
+
#[arg(short = 'i', long = "id", visible_alias = "issue")]
|
|
139
|
+
pub(crate) id: Option<String>,
|
|
140
|
+
|
|
141
|
+
/// Platform adapter to fetch from (used directly in CLI-only mode)
|
|
142
|
+
#[arg(short = 'p', long, value_enum)]
|
|
143
|
+
pub(crate) platform: Option<Platform>,
|
|
144
|
+
|
|
145
|
+
/// Named instance alias from .99problems ([instances.<alias>])
|
|
146
|
+
#[arg(short = 'I', long)]
|
|
147
|
+
pub(crate) instance: Option<String>,
|
|
148
|
+
|
|
149
|
+
/// Override platform base URL for one-off runs
|
|
150
|
+
#[arg(short = 'u', long)]
|
|
151
|
+
pub(crate) url: Option<String>,
|
|
152
|
+
|
|
153
|
+
/// Bitbucket deployment type (required for --platform bitbucket)
|
|
154
|
+
#[arg(long, value_enum)]
|
|
155
|
+
pub(crate) deployment: Option<DeploymentType>,
|
|
156
|
+
|
|
157
|
+
/// Content type to fetch (Bitbucket supports pull requests only; omitted type defaults to pr)
|
|
158
|
+
#[arg(short = 't', long = "type", value_enum)]
|
|
159
|
+
pub(crate) kind: Option<ContentType>,
|
|
160
|
+
|
|
161
|
+
/// Output format (default: text for TTY, jsonl for piped/file output)
|
|
162
|
+
#[arg(short = 'f', long, value_enum)]
|
|
163
|
+
pub(crate) format: Option<OutputFormat>,
|
|
164
|
+
|
|
165
|
+
/// Output behavior mode
|
|
166
|
+
#[arg(long, value_enum)]
|
|
167
|
+
pub(crate) output_mode: Option<OutputMode>,
|
|
168
|
+
|
|
169
|
+
/// Shorthand for --output-mode stream
|
|
170
|
+
#[arg(long, conflicts_with = "output_mode")]
|
|
171
|
+
pub(crate) stream: bool,
|
|
172
|
+
|
|
173
|
+
/// Include pull request review comments (inline code comments)
|
|
174
|
+
#[arg(short = 'R', long)]
|
|
175
|
+
pub(crate) include_review_comments: bool,
|
|
176
|
+
|
|
177
|
+
/// Skip fetching comments (faster, smaller output)
|
|
178
|
+
#[arg(long)]
|
|
179
|
+
pub(crate) no_comments: bool,
|
|
180
|
+
|
|
181
|
+
/// Write output to a file (default: stdout)
|
|
182
|
+
#[arg(short = 'o', long)]
|
|
183
|
+
pub(crate) output: Option<String>,
|
|
184
|
+
|
|
185
|
+
/// Personal access token (overrides env var and dotfile)
|
|
186
|
+
#[arg(short = 'k', long)]
|
|
187
|
+
pub(crate) token: Option<String>,
|
|
188
|
+
|
|
189
|
+
/// Account email used with Jira API-token basic auth
|
|
190
|
+
#[arg(long)]
|
|
191
|
+
pub(crate) account_email: Option<String>,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Run the `get` command.
|
|
195
|
+
///
|
|
196
|
+
/// # Errors
|
|
197
|
+
///
|
|
198
|
+
/// Returns an error if config resolution, request building, remote fetching,
|
|
199
|
+
/// or output writing fails.
|
|
200
|
+
pub(crate) fn run(args: &GetArgs) -> Result<()> {
|
|
201
|
+
let cfg = load_config_for_get(args)?;
|
|
202
|
+
emit_get_warnings(&cfg, args)?;
|
|
203
|
+
|
|
204
|
+
let source = build_source_for_platform(&cfg)?;
|
|
205
|
+
let req = build_fetch_request(&cfg, args)?;
|
|
206
|
+
let output_plan = resolve_output_plan(args);
|
|
207
|
+
debug!(
|
|
208
|
+
platform = %cfg.platform,
|
|
209
|
+
kind = %cfg.kind,
|
|
210
|
+
include_comments = !args.no_comments,
|
|
211
|
+
include_review_comments = args.include_review_comments,
|
|
212
|
+
output_mode = ?output_plan.mode,
|
|
213
|
+
output_format = ?output_plan.format,
|
|
214
|
+
"resolved get configuration"
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
match output_plan.mode {
|
|
218
|
+
ResolvedOutputMode::Batch => write_batch_output(
|
|
219
|
+
source.as_ref(),
|
|
220
|
+
&req,
|
|
221
|
+
output_plan.format,
|
|
222
|
+
args.output.as_deref(),
|
|
223
|
+
),
|
|
224
|
+
ResolvedOutputMode::Stream => write_stream_output(
|
|
225
|
+
source.as_ref(),
|
|
226
|
+
&req,
|
|
227
|
+
output_plan.format,
|
|
228
|
+
args.output.as_deref(),
|
|
229
|
+
),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn load_config_for_get(args: &GetArgs) -> Result<Config> {
|
|
234
|
+
if args.platform.is_none()
|
|
235
|
+
&& args.instance.is_none()
|
|
236
|
+
&& args.url.is_none()
|
|
237
|
+
&& args.deployment.is_none()
|
|
238
|
+
&& args.kind.is_none()
|
|
239
|
+
&& args.token.is_none()
|
|
240
|
+
&& args.account_email.is_none()
|
|
241
|
+
&& args.repo.is_none()
|
|
242
|
+
&& args.state.is_none()
|
|
243
|
+
{
|
|
244
|
+
return Config::load()
|
|
245
|
+
.map_err(|err| AppError::usage(format!("Config error: {err}")).into());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Config::load_with_options(ResolveOptions {
|
|
249
|
+
platform: args.platform.as_ref().map(Platform::as_str),
|
|
250
|
+
instance: args.instance.as_deref(),
|
|
251
|
+
url: args.url.as_deref(),
|
|
252
|
+
kind: args.kind.as_ref().map(ContentType::as_str),
|
|
253
|
+
deployment: args.deployment.as_ref().map(DeploymentType::as_str),
|
|
254
|
+
token: args.token.as_deref(),
|
|
255
|
+
account_email: args.account_email.as_deref(),
|
|
256
|
+
repo: args.repo.as_deref(),
|
|
257
|
+
state: args.state.as_deref(),
|
|
258
|
+
})
|
|
259
|
+
.map_err(|err| AppError::usage(format!("Config error: {err}")).into())
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn emit_get_warnings(cfg: &Config, args: &GetArgs) -> Result<()> {
|
|
263
|
+
if cfg.token.is_none() {
|
|
264
|
+
let env_var = token_env_var(&cfg.platform);
|
|
265
|
+
warn!(
|
|
266
|
+
"Warning: no token detected for {}. You may be subject to API rate limiting. Set --token, {}, or configure it in .99problems.",
|
|
267
|
+
cfg.platform, env_var
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if cfg.platform == "jira"
|
|
271
|
+
&& let Some(token) = cfg.token.as_deref()
|
|
272
|
+
&& looks_like_atlassian_api_token(token)
|
|
273
|
+
&& cfg.account_email.is_none()
|
|
274
|
+
{
|
|
275
|
+
warn!(
|
|
276
|
+
"Warning: Jira token looks like an Atlassian API token. Configure --account-email, JIRA_ACCOUNT_EMAIL, or [instances.<alias>].account_email, or provide --token as email:api_token."
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if args.no_comments && args.include_review_comments {
|
|
280
|
+
warn!("Warning: --include-review-comments is ignored when --no-comments is set.");
|
|
281
|
+
}
|
|
282
|
+
if cfg.platform == "jira" && cfg.kind == "pr" {
|
|
283
|
+
return Err(AppError::usage(
|
|
284
|
+
"Platform 'jira' does not support pull requests. Use --type issue.",
|
|
285
|
+
)
|
|
286
|
+
.into());
|
|
287
|
+
}
|
|
288
|
+
if cfg.platform == "bitbucket" && cfg.kind == "issue" && cfg.kind_explicit {
|
|
289
|
+
return Err(AppError::usage(
|
|
290
|
+
"Platform 'bitbucket' supports pull requests only. Use --type pr or omit --type.",
|
|
291
|
+
)
|
|
292
|
+
.into());
|
|
293
|
+
}
|
|
294
|
+
Ok(())
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn build_source_for_platform(cfg: &Config) -> Result<Box<dyn Source>> {
|
|
298
|
+
match cfg.platform.as_str() {
|
|
299
|
+
"github" => Ok(Box::new(GitHubSource::new()?)),
|
|
300
|
+
"gitlab" => Ok(Box::new(GitLabSource::new(cfg.platform_url.clone())?)),
|
|
301
|
+
"jira" => Ok(Box::new(JiraSource::new(cfg.platform_url.clone())?)),
|
|
302
|
+
"bitbucket" => Ok(Box::new(BitbucketSource::new(
|
|
303
|
+
cfg.platform_url.clone(),
|
|
304
|
+
cfg.deployment.clone(),
|
|
305
|
+
)?)),
|
|
306
|
+
other => Err(AppError::usage(format!("Platform '{other}' is not yet supported")).into()),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fn build_fetch_request(cfg: &Config, args: &GetArgs) -> Result<FetchRequest> {
|
|
311
|
+
let repo = cfg.repo.clone();
|
|
312
|
+
let state = cfg.state.clone();
|
|
313
|
+
let is_bitbucket = cfg.platform == "bitbucket";
|
|
314
|
+
let effective_kind = if is_bitbucket && cfg.kind == "issue" && !cfg.kind_explicit {
|
|
315
|
+
"pr"
|
|
316
|
+
} else {
|
|
317
|
+
cfg.kind.as_str()
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if let Some(id) = &args.id {
|
|
321
|
+
let ignored_flags = ignored_flags_in_id_mode(args);
|
|
322
|
+
if !ignored_flags.is_empty() {
|
|
323
|
+
warn!(
|
|
324
|
+
"Warning: when using --id/--issue, these flags are ignored: {}",
|
|
325
|
+
ignored_flags.join(", ")
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let id_kind = if effective_kind == "pr" {
|
|
330
|
+
ContentKind::Pr
|
|
331
|
+
} else {
|
|
332
|
+
ContentKind::Issue
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
let repo_for_id = if cfg.platform == "jira" {
|
|
336
|
+
repo.unwrap_or_default()
|
|
337
|
+
} else {
|
|
338
|
+
repo.ok_or_else(|| AppError::usage("--repo is required when using --id/--issue"))?
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return Ok(FetchRequest {
|
|
342
|
+
target: FetchTarget::Id {
|
|
343
|
+
repo: repo_for_id,
|
|
344
|
+
id: id.clone(),
|
|
345
|
+
kind: id_kind,
|
|
346
|
+
allow_fallback_to_pr: if is_bitbucket {
|
|
347
|
+
false
|
|
348
|
+
} else {
|
|
349
|
+
!cfg.kind_explicit && matches!(id_kind, ContentKind::Issue)
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
per_page: cfg.per_page,
|
|
353
|
+
token: cfg.token.clone(),
|
|
354
|
+
account_email: cfg.account_email.clone(),
|
|
355
|
+
include_comments: !args.no_comments,
|
|
356
|
+
include_review_comments: args.include_review_comments,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let query = Query::build(
|
|
361
|
+
args.query.clone(),
|
|
362
|
+
effective_kind,
|
|
363
|
+
repo,
|
|
364
|
+
state,
|
|
365
|
+
args.labels.clone(),
|
|
366
|
+
args.author.clone(),
|
|
367
|
+
args.since.clone(),
|
|
368
|
+
args.milestone.clone(),
|
|
369
|
+
cfg.per_page,
|
|
370
|
+
cfg.token.clone(),
|
|
371
|
+
);
|
|
372
|
+
if query.raw.trim().is_empty() {
|
|
373
|
+
return Err(AppError::usage(
|
|
374
|
+
"No query specified. Use -q or provide --repo/--state/--labels.",
|
|
375
|
+
)
|
|
376
|
+
.into());
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
Ok(FetchRequest {
|
|
380
|
+
target: FetchTarget::Search {
|
|
381
|
+
raw_query: query.raw,
|
|
382
|
+
},
|
|
383
|
+
per_page: query.per_page,
|
|
384
|
+
token: query.token,
|
|
385
|
+
account_email: cfg.account_email.clone(),
|
|
386
|
+
include_comments: !args.no_comments,
|
|
387
|
+
include_review_comments: args.include_review_comments,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
fn ignored_flags_in_id_mode(args: &GetArgs) -> Vec<&'static str> {
|
|
392
|
+
let mut ignored_flags = Vec::new();
|
|
393
|
+
if args.query.is_some() {
|
|
394
|
+
ignored_flags.push("--query");
|
|
395
|
+
}
|
|
396
|
+
if args.state.is_some() {
|
|
397
|
+
ignored_flags.push("--state");
|
|
398
|
+
}
|
|
399
|
+
if args.labels.is_some() {
|
|
400
|
+
ignored_flags.push("--labels");
|
|
401
|
+
}
|
|
402
|
+
if args.author.is_some() {
|
|
403
|
+
ignored_flags.push("--author");
|
|
404
|
+
}
|
|
405
|
+
if args.since.is_some() {
|
|
406
|
+
ignored_flags.push("--since");
|
|
407
|
+
}
|
|
408
|
+
if args.milestone.is_some() {
|
|
409
|
+
ignored_flags.push("--milestone");
|
|
410
|
+
}
|
|
411
|
+
ignored_flags
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn resolve_output_plan(args: &GetArgs) -> OutputPlan {
|
|
415
|
+
let stdout_is_tty = args.output.is_none() && std::io::stdout().is_terminal();
|
|
416
|
+
resolve_output_plan_with_tty(args, stdout_is_tty)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fn resolve_output_plan_with_tty(args: &GetArgs, stdout_is_tty: bool) -> OutputPlan {
|
|
420
|
+
let mode = if args.stream {
|
|
421
|
+
OutputMode::Stream
|
|
422
|
+
} else {
|
|
423
|
+
args.output_mode.unwrap_or(OutputMode::Auto)
|
|
424
|
+
};
|
|
425
|
+
let resolved_mode = match mode {
|
|
426
|
+
OutputMode::Batch => ResolvedOutputMode::Batch,
|
|
427
|
+
OutputMode::Auto | OutputMode::Stream => ResolvedOutputMode::Stream,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
let selected_format = args.format.unwrap_or({
|
|
431
|
+
if stdout_is_tty {
|
|
432
|
+
OutputFormat::Text
|
|
433
|
+
} else {
|
|
434
|
+
OutputFormat::Jsonl
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
let resolved_format = match selected_format {
|
|
438
|
+
OutputFormat::Json => ResolvedOutputFormat::Json,
|
|
439
|
+
OutputFormat::Yaml => ResolvedOutputFormat::Yaml,
|
|
440
|
+
OutputFormat::Jsonl | OutputFormat::Ndjson => ResolvedOutputFormat::Jsonl,
|
|
441
|
+
OutputFormat::Text => ResolvedOutputFormat::Text,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
OutputPlan {
|
|
445
|
+
mode: resolved_mode,
|
|
446
|
+
format: resolved_format,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fn build_formatter(format: ResolvedOutputFormat) -> Box<dyn StreamFormatter> {
|
|
451
|
+
match format {
|
|
452
|
+
ResolvedOutputFormat::Json => Box::new(JsonStreamFormatter::new()),
|
|
453
|
+
ResolvedOutputFormat::Yaml => Box::new(YamlStreamFormatter::new()),
|
|
454
|
+
ResolvedOutputFormat::Jsonl => Box::new(JsonLinesFormatter),
|
|
455
|
+
ResolvedOutputFormat::Text => Box::new(TextFormatter::new()),
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
fn write_batch_output(
|
|
460
|
+
source: &dyn Source,
|
|
461
|
+
req: &FetchRequest,
|
|
462
|
+
format: ResolvedOutputFormat,
|
|
463
|
+
output_path: Option<&str>,
|
|
464
|
+
) -> Result<()> {
|
|
465
|
+
let conversations = source.fetch(req)?;
|
|
466
|
+
let mut formatter = build_formatter(format);
|
|
467
|
+
let mut rendered = Vec::new();
|
|
468
|
+
formatter.begin(&mut rendered)?;
|
|
469
|
+
for conversation in &conversations {
|
|
470
|
+
formatter.write_item(&mut rendered, conversation)?;
|
|
471
|
+
}
|
|
472
|
+
formatter.finish(&mut rendered)?;
|
|
473
|
+
|
|
474
|
+
if let Some(path) = output_path {
|
|
475
|
+
let mut file = std::fs::File::create(path)?;
|
|
476
|
+
file.write_all(&rendered)?;
|
|
477
|
+
info!(count = conversations.len(), path = %path, "wrote conversations to file");
|
|
478
|
+
} else {
|
|
479
|
+
let mut out = std::io::stdout();
|
|
480
|
+
out.write_all(&rendered)?;
|
|
481
|
+
out.flush()?;
|
|
482
|
+
info!(count = conversations.len(), "wrote conversations to stdout");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
Ok(())
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
fn write_stream_output(
|
|
489
|
+
source: &dyn Source,
|
|
490
|
+
req: &FetchRequest,
|
|
491
|
+
format: ResolvedOutputFormat,
|
|
492
|
+
output_path: Option<&str>,
|
|
493
|
+
) -> Result<()> {
|
|
494
|
+
let mut formatter = build_formatter(format);
|
|
495
|
+
let mut writer: Box<dyn Write> = match output_path {
|
|
496
|
+
Some(path) => Box::new(std::fs::File::create(path)?),
|
|
497
|
+
None => Box::new(std::io::stdout()),
|
|
498
|
+
};
|
|
499
|
+
formatter.begin(&mut writer)?;
|
|
500
|
+
|
|
501
|
+
let mut emitted = 0usize;
|
|
502
|
+
let fetch_result = source.fetch_stream(req, &mut |conversation| {
|
|
503
|
+
formatter.write_item(&mut writer, &conversation)?;
|
|
504
|
+
emitted += 1;
|
|
505
|
+
Ok(())
|
|
506
|
+
});
|
|
507
|
+
if let Err(err) = fetch_result {
|
|
508
|
+
return Err(AppError::provider(format!(
|
|
509
|
+
"Fetch failed after writing {emitted} conversations: {err}"
|
|
510
|
+
))
|
|
511
|
+
.into());
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
formatter.finish(&mut writer)?;
|
|
515
|
+
writer.flush()?;
|
|
516
|
+
if let Some(path) = output_path {
|
|
517
|
+
info!(count = emitted, path = %path, "stream-wrote conversations to file");
|
|
518
|
+
} else {
|
|
519
|
+
info!(count = emitted, "stream-wrote conversations to stdout");
|
|
520
|
+
}
|
|
521
|
+
Ok(())
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
fn looks_like_atlassian_api_token(token: &str) -> bool {
|
|
525
|
+
token.starts_with("AT") && !token.contains(':') && !token.contains('.')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#[cfg(test)]
|
|
529
|
+
mod tests {
|
|
530
|
+
use super::*;
|
|
531
|
+
use crate::config::Config;
|
|
532
|
+
use crate::source::FetchTarget;
|
|
533
|
+
|
|
534
|
+
fn args() -> GetArgs {
|
|
535
|
+
GetArgs {
|
|
536
|
+
query: None,
|
|
537
|
+
repo: Some("owner/repo".into()),
|
|
538
|
+
state: None,
|
|
539
|
+
labels: None,
|
|
540
|
+
author: None,
|
|
541
|
+
since: None,
|
|
542
|
+
milestone: None,
|
|
543
|
+
id: Some("1".into()),
|
|
544
|
+
platform: None,
|
|
545
|
+
instance: None,
|
|
546
|
+
url: None,
|
|
547
|
+
deployment: None,
|
|
548
|
+
kind: None,
|
|
549
|
+
format: None,
|
|
550
|
+
output_mode: None,
|
|
551
|
+
stream: false,
|
|
552
|
+
include_review_comments: false,
|
|
553
|
+
no_comments: false,
|
|
554
|
+
output: None,
|
|
555
|
+
token: None,
|
|
556
|
+
account_email: None,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
fn bitbucket_config(deployment: &str, kind: &str, kind_explicit: bool) -> Config {
|
|
561
|
+
Config {
|
|
562
|
+
platform: "bitbucket".into(),
|
|
563
|
+
kind: kind.into(),
|
|
564
|
+
kind_explicit,
|
|
565
|
+
token: None,
|
|
566
|
+
account_email: None,
|
|
567
|
+
repo: Some("PROJECT/repo".into()),
|
|
568
|
+
state: None,
|
|
569
|
+
deployment: Some(deployment.into()),
|
|
570
|
+
per_page: 100,
|
|
571
|
+
platform_url: Some("https://bitbucket.example.com".into()),
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#[test]
|
|
576
|
+
fn resolve_output_plan_defaults_to_text_for_tty() {
|
|
577
|
+
let plan = resolve_output_plan_with_tty(&args(), true);
|
|
578
|
+
assert!(matches!(plan.mode, ResolvedOutputMode::Stream));
|
|
579
|
+
assert!(matches!(plan.format, ResolvedOutputFormat::Text));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
#[test]
|
|
583
|
+
fn resolve_output_plan_defaults_to_jsonl_for_non_tty() {
|
|
584
|
+
let plan = resolve_output_plan_with_tty(&args(), false);
|
|
585
|
+
assert!(matches!(plan.mode, ResolvedOutputMode::Stream));
|
|
586
|
+
assert!(matches!(plan.format, ResolvedOutputFormat::Jsonl));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#[test]
|
|
590
|
+
fn resolve_output_plan_honors_batch_mode() {
|
|
591
|
+
let mut args = args();
|
|
592
|
+
args.output_mode = Some(OutputMode::Batch);
|
|
593
|
+
let plan = resolve_output_plan_with_tty(&args, false);
|
|
594
|
+
assert!(matches!(plan.mode, ResolvedOutputMode::Batch));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#[test]
|
|
598
|
+
fn resolve_output_plan_stream_shorthand_wins() {
|
|
599
|
+
let mut args = args();
|
|
600
|
+
args.stream = true;
|
|
601
|
+
let plan = resolve_output_plan_with_tty(&args, false);
|
|
602
|
+
assert!(matches!(plan.mode, ResolvedOutputMode::Stream));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#[test]
|
|
606
|
+
fn resolve_output_plan_maps_ndjson_to_jsonl() {
|
|
607
|
+
let mut args = args();
|
|
608
|
+
args.format = Some(OutputFormat::Ndjson);
|
|
609
|
+
let plan = resolve_output_plan_with_tty(&args, false);
|
|
610
|
+
assert!(matches!(plan.format, ResolvedOutputFormat::Jsonl));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#[test]
|
|
614
|
+
fn bitbucket_id_defaults_to_pr_when_kind_is_implicit() {
|
|
615
|
+
let cfg = bitbucket_config("cloud", "issue", false);
|
|
616
|
+
let req = build_fetch_request(&cfg, &args()).unwrap();
|
|
617
|
+
match req.target {
|
|
618
|
+
FetchTarget::Id {
|
|
619
|
+
kind,
|
|
620
|
+
allow_fallback_to_pr,
|
|
621
|
+
..
|
|
622
|
+
} => {
|
|
623
|
+
assert!(matches!(kind, ContentKind::Pr));
|
|
624
|
+
assert!(!allow_fallback_to_pr);
|
|
625
|
+
}
|
|
626
|
+
FetchTarget::Search { .. } => panic!("expected id target"),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#[test]
|
|
631
|
+
fn bitbucket_cloud_explicit_issue_is_rejected() {
|
|
632
|
+
let cfg = bitbucket_config("cloud", "issue", true);
|
|
633
|
+
let err = emit_get_warnings(&cfg, &args()).unwrap_err().to_string();
|
|
634
|
+
assert!(err.contains("supports pull requests only"));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#[test]
|
|
638
|
+
fn bitbucket_selfhosted_explicit_issue_is_rejected() {
|
|
639
|
+
let cfg = bitbucket_config("selfhosted", "issue", true);
|
|
640
|
+
let err = emit_get_warnings(&cfg, &args()).unwrap_err().to_string();
|
|
641
|
+
assert!(err.contains("supports pull requests only"));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
#[test]
|
|
645
|
+
fn bitbucket_search_defaults_to_pr_when_kind_is_implicit() {
|
|
646
|
+
let cfg = bitbucket_config("cloud", "issue", false);
|
|
647
|
+
let mut args = args();
|
|
648
|
+
args.id = None;
|
|
649
|
+
let req = build_fetch_request(&cfg, &args).unwrap();
|
|
650
|
+
match req.target {
|
|
651
|
+
FetchTarget::Search { raw_query } => {
|
|
652
|
+
assert!(raw_query.contains("is:pr"));
|
|
653
|
+
assert!(!raw_query.contains("is:issue"));
|
|
654
|
+
}
|
|
655
|
+
FetchTarget::Id { .. } => panic!("expected search target"),
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|