@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.
- 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 +49 -9
- package/CONTRIBUTING.md +38 -50
- package/Cargo.lock +151 -108
- package/Cargo.toml +8 -3
- 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/main.rs
CHANGED
|
@@ -1,179 +1,266 @@
|
|
|
1
|
+
mod cmd;
|
|
1
2
|
mod config;
|
|
3
|
+
mod error;
|
|
2
4
|
mod format;
|
|
5
|
+
mod logging;
|
|
3
6
|
mod model;
|
|
4
7
|
mod source;
|
|
5
8
|
|
|
6
|
-
use
|
|
7
|
-
use
|
|
9
|
+
use clap::{ArgAction, CommandFactory, Parser, Subcommand, ValueEnum};
|
|
10
|
+
use clap_complete::{Generator, Shell, generate};
|
|
8
11
|
use std::io::Write;
|
|
12
|
+
use tracing::error;
|
|
9
13
|
|
|
10
|
-
use
|
|
11
|
-
use format::{Formatter, json::JsonFormatter, yaml::YamlFormatter};
|
|
12
|
-
use source::{Query, Source, github_issues::GitHubIssues};
|
|
14
|
+
use crate::error::{AppError, classify_anyhow_error};
|
|
13
15
|
|
|
14
16
|
#[derive(Debug, Clone, ValueEnum)]
|
|
15
|
-
enum
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
enum Platform {
|
|
22
|
-
Github,
|
|
23
|
-
Gitlab,
|
|
24
|
-
Bitbucket,
|
|
17
|
+
enum CompletionShell {
|
|
18
|
+
Bash,
|
|
19
|
+
Zsh,
|
|
20
|
+
Fish,
|
|
21
|
+
Powershell,
|
|
22
|
+
Elvish,
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
impl
|
|
28
|
-
fn
|
|
25
|
+
impl CompletionShell {
|
|
26
|
+
fn as_clap_shell(&self) -> Shell {
|
|
29
27
|
match self {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
CompletionShell::Bash => Shell::Bash,
|
|
29
|
+
CompletionShell::Zsh => Shell::Zsh,
|
|
30
|
+
CompletionShell::Fish => Shell::Fish,
|
|
31
|
+
CompletionShell::Powershell => Shell::PowerShell,
|
|
32
|
+
CompletionShell::Elvish => Shell::Elvish,
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
#[derive(Debug, Clone, ValueEnum)]
|
|
38
|
-
enum
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
impl ContentType {
|
|
44
|
-
fn as_str(&self) -> &str {
|
|
45
|
-
match self {
|
|
46
|
-
ContentType::Issue => "issue",
|
|
47
|
-
ContentType::Pr => "pr",
|
|
48
|
-
}
|
|
49
|
-
}
|
|
37
|
+
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
38
|
+
enum ErrorFormat {
|
|
39
|
+
Text,
|
|
40
|
+
Json,
|
|
50
41
|
}
|
|
51
42
|
|
|
52
43
|
#[derive(Parser, Debug)]
|
|
53
44
|
#[command(
|
|
54
45
|
name = "99problems",
|
|
55
|
-
about = "Fetch
|
|
46
|
+
about = "Fetch issue and pull request conversations",
|
|
47
|
+
long_about = "Fetch issue and pull request conversations from GitHub, GitLab, and Jira.",
|
|
48
|
+
subcommand_required = true,
|
|
49
|
+
arg_required_else_help = true,
|
|
50
|
+
next_line_help = true,
|
|
51
|
+
after_help = "Examples:\n 99problems get --repo schemaorg/schemaorg --id 1842\n 99problems get -q \"repo:github/gitignore is:pr 2402\" --include-review-comments\n 99problems man --output docs/man",
|
|
56
52
|
version
|
|
57
53
|
)]
|
|
58
54
|
struct Cli {
|
|
59
|
-
///
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
#[arg(long)]
|
|
74
|
-
labels: Option<String>,
|
|
75
|
-
|
|
76
|
-
/// Filter by issue/PR author
|
|
77
|
-
#[arg(long)]
|
|
78
|
-
author: Option<String>,
|
|
79
|
-
|
|
80
|
-
/// Only include items created on or after this date (YYYY-MM-DD), e.g. "2024-01-01"
|
|
81
|
-
#[arg(long)]
|
|
82
|
-
since: Option<String>,
|
|
83
|
-
|
|
84
|
-
/// Filter by milestone title or number
|
|
85
|
-
#[arg(long)]
|
|
86
|
-
milestone: Option<String>,
|
|
87
|
-
|
|
88
|
-
/// Fetch a single issue by number (bypasses search)
|
|
89
|
-
#[arg(long)]
|
|
90
|
-
issue: Option<u64>,
|
|
91
|
-
|
|
92
|
-
/// Platform to fetch from [default: github]
|
|
93
|
-
#[arg(long, value_enum)]
|
|
94
|
-
platform: Option<Platform>,
|
|
95
|
-
|
|
96
|
-
/// Content type to fetch [default: issue]
|
|
97
|
-
#[arg(long = "type", value_enum)]
|
|
98
|
-
kind: Option<ContentType>,
|
|
99
|
-
|
|
100
|
-
/// Output format
|
|
101
|
-
#[arg(long, value_enum, default_value = "json")]
|
|
102
|
-
format: OutputFormat,
|
|
103
|
-
|
|
104
|
-
/// Write output to a file (default: stdout)
|
|
105
|
-
#[arg(short = 'o', long)]
|
|
106
|
-
output: Option<String>,
|
|
107
|
-
|
|
108
|
-
/// Personal access token (overrides env var and dotfile)
|
|
109
|
-
#[arg(long)]
|
|
110
|
-
token: Option<String>,
|
|
55
|
+
/// Increase diagnostic verbosity (-v, -vv, -vvv)
|
|
56
|
+
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true, conflicts_with = "quiet")]
|
|
57
|
+
verbose: u8,
|
|
58
|
+
|
|
59
|
+
/// Suppress non-error diagnostics
|
|
60
|
+
#[arg(short = 'Q', long = "quiet", global = true)]
|
|
61
|
+
quiet: bool,
|
|
62
|
+
|
|
63
|
+
/// Error output format
|
|
64
|
+
#[arg(long, value_enum, default_value = "text", global = true)]
|
|
65
|
+
error_format: ErrorFormat,
|
|
66
|
+
|
|
67
|
+
#[command(subcommand)]
|
|
68
|
+
command: Commands,
|
|
111
69
|
}
|
|
112
70
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
71
|
+
#[derive(Subcommand, Debug)]
|
|
72
|
+
enum Commands {
|
|
73
|
+
/// Fetch issue and pull request conversations
|
|
74
|
+
#[command(visible_alias = "got")]
|
|
75
|
+
Get(Box<cmd::get::GetArgs>),
|
|
116
76
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
77
|
+
/// Inspect and edit .99problems configuration
|
|
78
|
+
Config(cmd::config::ConfigArgs),
|
|
79
|
+
|
|
80
|
+
/// Generate shell completion script and print it to stdout
|
|
81
|
+
Completions {
|
|
82
|
+
#[arg(value_enum)]
|
|
83
|
+
shell: CompletionShell,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/// Generate and print/write man pages
|
|
87
|
+
Man(cmd::man::ManArgs),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn main() {
|
|
91
|
+
let cli = Cli::parse();
|
|
92
|
+
if let Err(err) = logging::init(cli.verbose, cli.quiet) {
|
|
93
|
+
let app_err = classify_anyhow_error(&err);
|
|
94
|
+
render_and_exit(&app_err, cli.error_format);
|
|
120
95
|
}
|
|
121
|
-
|
|
122
|
-
|
|
96
|
+
|
|
97
|
+
let result = match cli.command {
|
|
98
|
+
Commands::Get(args) => cmd::get::run(&args),
|
|
99
|
+
Commands::Config(args) => cmd::config::run(&args),
|
|
100
|
+
Commands::Completions { shell } => {
|
|
101
|
+
print_completions(shell.as_clap_shell(), &mut std::io::stdout());
|
|
102
|
+
Ok(())
|
|
103
|
+
}
|
|
104
|
+
Commands::Man(args) => cmd::man::run(Cli::command(), &args),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if let Err(err) = result {
|
|
108
|
+
let app_err = classify_anyhow_error(&err);
|
|
109
|
+
render_and_exit(&app_err, cli.error_format);
|
|
123
110
|
}
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn print_completions<G: Generator>(generator: G, out: &mut dyn Write) {
|
|
114
|
+
let mut cmd = Cli::command();
|
|
115
|
+
let name = cmd.get_name().to_string();
|
|
116
|
+
generate(generator, &mut cmd, name, out);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fn render_and_exit(app_err: &AppError, format: ErrorFormat) -> ! {
|
|
120
|
+
match format {
|
|
121
|
+
ErrorFormat::Text => eprintln!("Error: {}", app_err.render_text()),
|
|
122
|
+
ErrorFormat::Json => eprintln!("{}", app_err.render_json()),
|
|
126
123
|
}
|
|
124
|
+
error!(
|
|
125
|
+
category = app_err.category().code(),
|
|
126
|
+
exit_code = app_err.exit_code(),
|
|
127
|
+
"command failed"
|
|
128
|
+
);
|
|
129
|
+
std::process::exit(app_err.exit_code());
|
|
130
|
+
}
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
#[cfg(test)]
|
|
133
|
+
mod tests {
|
|
134
|
+
use super::*;
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
#[test]
|
|
137
|
+
fn parses_get_subcommand() {
|
|
138
|
+
let cli = Cli::try_parse_from(["99problems", "get", "--repo", "owner/repo", "--id", "1"])
|
|
139
|
+
.expect("expected get subcommand to parse");
|
|
140
|
+
match cli.command {
|
|
141
|
+
Commands::Get(args) => {
|
|
142
|
+
assert_eq!(args.repo.as_deref(), Some("owner/repo"));
|
|
143
|
+
assert_eq!(args.id.as_deref(), Some("1"));
|
|
144
|
+
}
|
|
145
|
+
Commands::Config(_) | Commands::Completions { .. } | Commands::Man(_) => {
|
|
146
|
+
panic!("expected get command")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
135
150
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
#[test]
|
|
152
|
+
fn parses_got_alias_to_get_subcommand() {
|
|
153
|
+
let cli = Cli::try_parse_from(["99problems", "got", "--repo", "owner/repo", "--id", "2"])
|
|
154
|
+
.expect("expected got alias to parse");
|
|
155
|
+
match cli.command {
|
|
156
|
+
Commands::Get(args) => {
|
|
157
|
+
assert_eq!(args.repo.as_deref(), Some("owner/repo"));
|
|
158
|
+
assert_eq!(args.id.as_deref(), Some("2"));
|
|
159
|
+
}
|
|
160
|
+
Commands::Config(_) | Commands::Completions { .. } | Commands::Man(_) => {
|
|
161
|
+
panic!("expected get command")
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
140
165
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
cfg.per_page,
|
|
157
|
-
cfg.token,
|
|
158
|
-
);
|
|
159
|
-
if query.raw.trim().is_empty() {
|
|
160
|
-
return Err(anyhow!(
|
|
161
|
-
"No query specified. Use -q or provide --repo/--state/--labels."
|
|
162
|
-
));
|
|
166
|
+
#[test]
|
|
167
|
+
fn parses_config_subcommand() {
|
|
168
|
+
let cli = Cli::try_parse_from([
|
|
169
|
+
"99problems",
|
|
170
|
+
"config",
|
|
171
|
+
"set",
|
|
172
|
+
"instances.work.platform",
|
|
173
|
+
"gitlab",
|
|
174
|
+
])
|
|
175
|
+
.expect("expected config command to parse");
|
|
176
|
+
match cli.command {
|
|
177
|
+
Commands::Config(_) => {}
|
|
178
|
+
Commands::Get(_) | Commands::Completions { .. } | Commands::Man(_) => {
|
|
179
|
+
panic!("expected config command")
|
|
180
|
+
}
|
|
163
181
|
}
|
|
164
|
-
|
|
165
|
-
};
|
|
182
|
+
}
|
|
166
183
|
|
|
167
|
-
|
|
184
|
+
#[test]
|
|
185
|
+
fn parses_completions_subcommand() {
|
|
186
|
+
let cli = Cli::try_parse_from(["99problems", "completions", "bash"])
|
|
187
|
+
.expect("expected completions command to parse");
|
|
188
|
+
match cli.command {
|
|
189
|
+
Commands::Completions { shell } => match shell {
|
|
190
|
+
CompletionShell::Bash => {}
|
|
191
|
+
CompletionShell::Zsh
|
|
192
|
+
| CompletionShell::Fish
|
|
193
|
+
| CompletionShell::Powershell
|
|
194
|
+
| CompletionShell::Elvish => panic!("expected bash shell"),
|
|
195
|
+
},
|
|
196
|
+
Commands::Get(_) | Commands::Config(_) | Commands::Man(_) => {
|
|
197
|
+
panic!("expected completions command")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
168
201
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
202
|
+
#[test]
|
|
203
|
+
fn parses_repeated_verbose_flag() {
|
|
204
|
+
let cli = Cli::try_parse_from([
|
|
205
|
+
"99problems",
|
|
206
|
+
"-vv",
|
|
207
|
+
"get",
|
|
208
|
+
"--repo",
|
|
209
|
+
"owner/repo",
|
|
210
|
+
"--id",
|
|
211
|
+
"1",
|
|
212
|
+
])
|
|
213
|
+
.expect("expected repeated verbosity flags to parse");
|
|
214
|
+
assert_eq!(cli.verbose, 2);
|
|
215
|
+
assert!(!cli.quiet);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn rejects_quiet_and_verbose_together() {
|
|
220
|
+
let err = Cli::try_parse_from([
|
|
221
|
+
"99problems",
|
|
222
|
+
"--quiet",
|
|
223
|
+
"-v",
|
|
224
|
+
"get",
|
|
225
|
+
"--repo",
|
|
226
|
+
"owner/repo",
|
|
227
|
+
"--id",
|
|
228
|
+
"1",
|
|
229
|
+
])
|
|
230
|
+
.expect_err("expected conflict between --quiet and -v");
|
|
231
|
+
let message = err.to_string();
|
|
232
|
+
assert!(message.contains("--quiet"));
|
|
233
|
+
assert!(message.contains("--verbose"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#[test]
|
|
237
|
+
fn parses_error_format_json() {
|
|
238
|
+
let cli = Cli::try_parse_from([
|
|
239
|
+
"99problems",
|
|
240
|
+
"--error-format",
|
|
241
|
+
"json",
|
|
242
|
+
"get",
|
|
243
|
+
"--repo",
|
|
244
|
+
"owner/repo",
|
|
245
|
+
"--id",
|
|
246
|
+
"1",
|
|
247
|
+
])
|
|
248
|
+
.expect("expected --error-format json to parse");
|
|
249
|
+
match cli.error_format {
|
|
250
|
+
ErrorFormat::Json => {}
|
|
251
|
+
ErrorFormat::Text => panic!("expected json error format"),
|
|
174
252
|
}
|
|
175
|
-
None => println!("{output}"),
|
|
176
253
|
}
|
|
177
254
|
|
|
178
|
-
|
|
255
|
+
#[test]
|
|
256
|
+
fn parses_man_subcommand() {
|
|
257
|
+
let cli = Cli::try_parse_from(["99problems", "man", "--output", "docs/man"])
|
|
258
|
+
.expect("expected man command to parse");
|
|
259
|
+
match cli.command {
|
|
260
|
+
Commands::Man(_) => {}
|
|
261
|
+
Commands::Get(_) | Commands::Config(_) | Commands::Completions { .. } => {
|
|
262
|
+
panic!("expected man command")
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
179
266
|
}
|
package/src/model.rs
CHANGED
|
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|
|
2
2
|
|
|
3
3
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
4
4
|
pub struct Conversation {
|
|
5
|
-
pub id:
|
|
5
|
+
pub id: String,
|
|
6
6
|
pub title: String,
|
|
7
7
|
pub state: String,
|
|
8
8
|
pub body: Option<String>,
|
|
@@ -14,4 +14,12 @@ pub struct Comment {
|
|
|
14
14
|
pub author: Option<String>,
|
|
15
15
|
pub created_at: String,
|
|
16
16
|
pub body: Option<String>,
|
|
17
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
18
|
+
pub kind: Option<String>,
|
|
19
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
20
|
+
pub review_path: Option<String>,
|
|
21
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
22
|
+
pub review_line: Option<u64>,
|
|
23
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
24
|
+
pub review_side: Option<String>,
|
|
17
25
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use reqwest::StatusCode;
|
|
3
|
+
use serde::de::DeserializeOwned;
|
|
4
|
+
use tracing::{debug, trace};
|
|
5
|
+
|
|
6
|
+
use super::super::shared::{apply_auth, parse_bitbucket_json, send};
|
|
7
|
+
use super::super::{BitbucketSource, PAGE_SIZE};
|
|
8
|
+
use super::model::BitbucketPage;
|
|
9
|
+
|
|
10
|
+
impl BitbucketSource {
|
|
11
|
+
pub(super) fn bounded_per_page(per_page: u32) -> u32 {
|
|
12
|
+
per_page.clamp(1, PAGE_SIZE)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pub(super) fn cloud_get_one<T: DeserializeOwned>(
|
|
16
|
+
&self,
|
|
17
|
+
url: &str,
|
|
18
|
+
token: Option<&str>,
|
|
19
|
+
operation: &str,
|
|
20
|
+
) -> Result<Option<T>> {
|
|
21
|
+
let request = apply_auth(self.client.get(url), token).header("Accept", "application/json");
|
|
22
|
+
let response = send(request, operation)?;
|
|
23
|
+
if response.status() == StatusCode::NOT_FOUND {
|
|
24
|
+
return Ok(None);
|
|
25
|
+
}
|
|
26
|
+
let item = parse_bitbucket_json(response, token, operation)?;
|
|
27
|
+
Ok(Some(item))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[allow(clippy::too_many_arguments)]
|
|
31
|
+
pub(super) fn cloud_get_pages_stream<T: DeserializeOwned>(
|
|
32
|
+
&self,
|
|
33
|
+
url: &str,
|
|
34
|
+
params: &[(String, String)],
|
|
35
|
+
token: Option<&str>,
|
|
36
|
+
per_page: u32,
|
|
37
|
+
emit: &mut dyn FnMut(T) -> Result<()>,
|
|
38
|
+
) -> Result<usize> {
|
|
39
|
+
let per_page = Self::bounded_per_page(per_page);
|
|
40
|
+
let mut emitted = 0usize;
|
|
41
|
+
let mut next_url = Some(url.to_string());
|
|
42
|
+
let mut first = true;
|
|
43
|
+
|
|
44
|
+
while let Some(current_url) = next_url {
|
|
45
|
+
debug!(url = %current_url, per_page, "fetching Bitbucket cloud page");
|
|
46
|
+
let mut request = apply_auth(self.client.get(¤t_url), token)
|
|
47
|
+
.header("Accept", "application/json");
|
|
48
|
+
if first {
|
|
49
|
+
let mut merged_params = params.to_vec();
|
|
50
|
+
merged_params.push(("pagelen".to_string(), per_page.to_string()));
|
|
51
|
+
request = request.query(&merged_params);
|
|
52
|
+
first = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let response = send(request, "page fetch")?;
|
|
56
|
+
let page: BitbucketPage<T> = parse_bitbucket_json(response, token, "page fetch")?;
|
|
57
|
+
trace!(count = page.values.len(), "decoded Bitbucket cloud page");
|
|
58
|
+
for item in page.values {
|
|
59
|
+
emit(item)?;
|
|
60
|
+
emitted += 1;
|
|
61
|
+
}
|
|
62
|
+
next_url = page.next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Ok(emitted)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
|
|
3
|
+
use self::model::{
|
|
4
|
+
BitbucketCommentItem, BitbucketPullRequestItem, map_pr_comment, matches_pr_filters,
|
|
5
|
+
};
|
|
6
|
+
use super::BitbucketSource;
|
|
7
|
+
use super::query::{BitbucketFilters, parse_bitbucket_query, parse_workspace_repo};
|
|
8
|
+
use crate::error::AppError;
|
|
9
|
+
use crate::model::Conversation;
|
|
10
|
+
use crate::source::{ContentKind, FetchRequest, FetchTarget};
|
|
11
|
+
|
|
12
|
+
mod api;
|
|
13
|
+
mod model;
|
|
14
|
+
|
|
15
|
+
impl BitbucketSource {
|
|
16
|
+
pub(super) fn fetch_cloud_stream(
|
|
17
|
+
&self,
|
|
18
|
+
req: &FetchRequest,
|
|
19
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
20
|
+
) -> Result<usize> {
|
|
21
|
+
match &req.target {
|
|
22
|
+
FetchTarget::Search { raw_query } => self.search_stream(req, raw_query, emit),
|
|
23
|
+
FetchTarget::Id {
|
|
24
|
+
repo,
|
|
25
|
+
id,
|
|
26
|
+
kind,
|
|
27
|
+
allow_fallback_to_pr: _,
|
|
28
|
+
} => self.fetch_by_id_stream(req, repo, id, *kind, emit),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn search_stream(
|
|
33
|
+
&self,
|
|
34
|
+
req: &FetchRequest,
|
|
35
|
+
raw_query: &str,
|
|
36
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
37
|
+
) -> Result<usize> {
|
|
38
|
+
let filters = parse_bitbucket_query(raw_query);
|
|
39
|
+
if matches!(filters.kind, ContentKind::Issue) {
|
|
40
|
+
return Err(AppError::usage(
|
|
41
|
+
"Bitbucket Cloud supports pull requests only. Use --type pr or omit --type.",
|
|
42
|
+
)
|
|
43
|
+
.into());
|
|
44
|
+
}
|
|
45
|
+
let (workspace, repo_slug) = parse_workspace_repo(filters.repo.as_deref())?;
|
|
46
|
+
let repo = format!("{workspace}/{repo_slug}");
|
|
47
|
+
|
|
48
|
+
self.search_prs_stream(req, &repo, &filters, emit)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn search_prs_stream(
|
|
52
|
+
&self,
|
|
53
|
+
req: &FetchRequest,
|
|
54
|
+
repo: &str,
|
|
55
|
+
filters: &BitbucketFilters,
|
|
56
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
57
|
+
) -> Result<usize> {
|
|
58
|
+
let url = format!("{}/repositories/{repo}/pullrequests", self.base_url);
|
|
59
|
+
let params = vec![
|
|
60
|
+
("sort".to_string(), "-updated_on".to_string()),
|
|
61
|
+
("state".to_string(), "ALL".to_string()),
|
|
62
|
+
];
|
|
63
|
+
let mut emitted = 0usize;
|
|
64
|
+
self.cloud_get_pages_stream(
|
|
65
|
+
&url,
|
|
66
|
+
¶ms,
|
|
67
|
+
req.token.as_deref(),
|
|
68
|
+
req.per_page,
|
|
69
|
+
&mut |item: BitbucketPullRequestItem| {
|
|
70
|
+
if !matches_pr_filters(&item, filters) {
|
|
71
|
+
return Ok(());
|
|
72
|
+
}
|
|
73
|
+
let conversation = self.fetch_pr_conversation(repo, item, req)?;
|
|
74
|
+
emit(conversation)?;
|
|
75
|
+
emitted += 1;
|
|
76
|
+
Ok(())
|
|
77
|
+
},
|
|
78
|
+
)?;
|
|
79
|
+
Ok(emitted)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn fetch_by_id_stream(
|
|
83
|
+
&self,
|
|
84
|
+
req: &FetchRequest,
|
|
85
|
+
repo: &str,
|
|
86
|
+
id: &str,
|
|
87
|
+
kind: ContentKind,
|
|
88
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
89
|
+
) -> Result<usize> {
|
|
90
|
+
let (workspace, repo_slug) = parse_workspace_repo(Some(repo))?;
|
|
91
|
+
let repo = format!("{workspace}/{repo_slug}");
|
|
92
|
+
let id = id.parse::<u64>().map_err(|_| {
|
|
93
|
+
AppError::usage(format!(
|
|
94
|
+
"Bitbucket Cloud expects a numeric pull request id, got '{id}'."
|
|
95
|
+
))
|
|
96
|
+
})?;
|
|
97
|
+
match kind {
|
|
98
|
+
ContentKind::Issue => Err(AppError::usage(
|
|
99
|
+
"Bitbucket Cloud supports pull requests only. Use --type pr or omit --type.",
|
|
100
|
+
)
|
|
101
|
+
.into()),
|
|
102
|
+
ContentKind::Pr => {
|
|
103
|
+
if let Some(pr) = self.fetch_pr_by_id(&repo, id, req)? {
|
|
104
|
+
emit(self.fetch_pr_conversation(&repo, pr, req)?)?;
|
|
105
|
+
return Ok(1);
|
|
106
|
+
}
|
|
107
|
+
Err(
|
|
108
|
+
AppError::not_found(format!("Pull request #{id} not found in repo {repo}."))
|
|
109
|
+
.into(),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn fetch_pr_by_id(
|
|
116
|
+
&self,
|
|
117
|
+
repo: &str,
|
|
118
|
+
id: u64,
|
|
119
|
+
req: &FetchRequest,
|
|
120
|
+
) -> Result<Option<BitbucketPullRequestItem>> {
|
|
121
|
+
let url = format!("{}/repositories/{repo}/pullrequests/{id}", self.base_url);
|
|
122
|
+
self.cloud_get_one(&url, req.token.as_deref(), "pull request fetch")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn fetch_pr_conversation(
|
|
126
|
+
&self,
|
|
127
|
+
repo: &str,
|
|
128
|
+
item: BitbucketPullRequestItem,
|
|
129
|
+
req: &FetchRequest,
|
|
130
|
+
) -> Result<Conversation> {
|
|
131
|
+
let comments = if req.include_comments {
|
|
132
|
+
self.fetch_pr_comments(repo, item.id, req)?
|
|
133
|
+
} else {
|
|
134
|
+
Vec::new()
|
|
135
|
+
};
|
|
136
|
+
let body = item
|
|
137
|
+
.description
|
|
138
|
+
.or_else(|| item.summary.and_then(|s| s.raw))
|
|
139
|
+
.filter(|b| !b.is_empty());
|
|
140
|
+
Ok(Conversation {
|
|
141
|
+
id: item.id.to_string(),
|
|
142
|
+
title: item.title,
|
|
143
|
+
state: item.state,
|
|
144
|
+
body,
|
|
145
|
+
comments,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn fetch_pr_comments(
|
|
150
|
+
&self,
|
|
151
|
+
repo: &str,
|
|
152
|
+
id: u64,
|
|
153
|
+
req: &FetchRequest,
|
|
154
|
+
) -> Result<Vec<crate::model::Comment>> {
|
|
155
|
+
let url = format!(
|
|
156
|
+
"{}/repositories/{repo}/pullrequests/{id}/comments",
|
|
157
|
+
self.base_url
|
|
158
|
+
);
|
|
159
|
+
let mut comments = Vec::new();
|
|
160
|
+
self.cloud_get_pages_stream(
|
|
161
|
+
&url,
|
|
162
|
+
&[],
|
|
163
|
+
req.token.as_deref(),
|
|
164
|
+
req.per_page,
|
|
165
|
+
&mut |item: BitbucketCommentItem| {
|
|
166
|
+
if item.deleted.unwrap_or(false) {
|
|
167
|
+
return Ok(());
|
|
168
|
+
}
|
|
169
|
+
if let Some(mapped) = map_pr_comment(item, req.include_review_comments) {
|
|
170
|
+
comments.push(mapped);
|
|
171
|
+
}
|
|
172
|
+
Ok(())
|
|
173
|
+
},
|
|
174
|
+
)?;
|
|
175
|
+
comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
|
176
|
+
Ok(comments)
|
|
177
|
+
}
|
|
178
|
+
}
|