@mbe24/99problems 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
- package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
- package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/dependabot.yml +20 -0
- package/.github/scripts/publish.js +1 -1
- package/.github/workflows/ci.yml +32 -6
- package/.github/workflows/man-drift.yml +26 -0
- package/.github/workflows/release.yml +7 -7
- package/CONTRIBUTING.md +38 -50
- package/Cargo.lock +151 -108
- package/Cargo.toml +8 -3
- package/README.md +109 -72
- package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
- package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
- package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
- package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
- package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
- package/docs/man/99problems-completions.1 +31 -0
- package/docs/man/99problems-config.1 +50 -0
- package/docs/man/99problems-get.1 +114 -0
- package/docs/man/99problems-help.1 +25 -0
- package/docs/man/99problems-man.1 +32 -0
- package/docs/man/99problems.1 +52 -0
- package/npm/install.js +90 -3
- package/package.json +7 -7
- package/rust-toolchain.toml +4 -0
- package/src/cmd/config/key.rs +126 -0
- package/src/cmd/config/mod.rs +218 -0
- package/src/cmd/config/render.rs +33 -0
- package/src/cmd/config/store.rs +318 -0
- package/src/cmd/config/write.rs +173 -0
- package/src/cmd/get.rs +658 -0
- package/src/cmd/man.rs +117 -0
- package/src/cmd/mod.rs +3 -0
- package/src/config.rs +641 -42
- package/src/error.rs +254 -0
- package/src/format/json.rs +59 -18
- package/src/format/jsonl.rs +52 -0
- package/src/format/mod.rs +25 -3
- package/src/format/text.rs +73 -0
- package/src/format/yaml.rs +64 -15
- package/src/lib.rs +1 -0
- package/src/logging.rs +54 -0
- package/src/main.rs +230 -91
- package/src/model.rs +9 -1
- package/src/source/bitbucket/cloud/api.rs +67 -0
- package/src/source/bitbucket/cloud/mod.rs +178 -0
- package/src/source/bitbucket/cloud/model.rs +211 -0
- package/src/source/bitbucket/datacenter/api.rs +74 -0
- package/src/source/bitbucket/datacenter/mod.rs +181 -0
- package/src/source/bitbucket/datacenter/model.rs +327 -0
- package/src/source/bitbucket/mod.rs +90 -0
- package/src/source/bitbucket/query.rs +169 -0
- package/src/source/bitbucket/shared/auth.rs +54 -0
- package/src/source/bitbucket/shared/http.rs +59 -0
- package/src/source/bitbucket/shared/mod.rs +5 -0
- package/src/source/github/api.rs +128 -0
- package/src/source/github/mod.rs +191 -0
- package/src/source/github/model.rs +84 -0
- package/src/source/github/query.rs +50 -0
- package/src/source/gitlab/api.rs +282 -0
- package/src/source/gitlab/mod.rs +225 -0
- package/src/source/gitlab/model.rs +102 -0
- package/src/source/gitlab/query.rs +177 -0
- package/src/source/jira/api.rs +222 -0
- package/src/source/jira/mod.rs +161 -0
- package/src/source/jira/model.rs +99 -0
- package/src/source/jira/query.rs +153 -0
- package/src/source/mod.rs +179 -24
- package/tests/integration.rs +406 -26
- package/src/source/github_issues.rs +0 -232
package/src/main.rs
CHANGED
|
@@ -1,127 +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
|
-
|
|
17
|
+
enum CompletionShell {
|
|
18
|
+
Bash,
|
|
19
|
+
Zsh,
|
|
20
|
+
Fish,
|
|
21
|
+
Powershell,
|
|
22
|
+
Elvish,
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
impl CompletionShell {
|
|
26
|
+
fn as_clap_shell(&self) -> Shell {
|
|
27
|
+
match self {
|
|
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
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
38
|
+
enum ErrorFormat {
|
|
39
|
+
Text,
|
|
40
|
+
Json,
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
#[derive(Parser, Debug)]
|
|
27
44
|
#[command(
|
|
28
45
|
name = "99problems",
|
|
29
|
-
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",
|
|
30
52
|
version
|
|
31
53
|
)]
|
|
32
54
|
struct Cli {
|
|
33
|
-
///
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#[arg(long)]
|
|
48
|
-
labels: Option<String>,
|
|
49
|
-
|
|
50
|
-
/// Fetch a single issue by number (bypasses search)
|
|
51
|
-
#[arg(long)]
|
|
52
|
-
issue: Option<u64>,
|
|
53
|
-
|
|
54
|
-
/// Data source to use
|
|
55
|
-
#[arg(long, value_enum, default_value = "github-issues")]
|
|
56
|
-
source: SourceKind,
|
|
57
|
-
|
|
58
|
-
/// Output format
|
|
59
|
-
#[arg(long, value_enum, default_value = "json")]
|
|
60
|
-
format: OutputFormat,
|
|
61
|
-
|
|
62
|
-
/// Write output to a file (default: stdout)
|
|
63
|
-
#[arg(short = 'o', long)]
|
|
64
|
-
output: Option<String>,
|
|
65
|
-
|
|
66
|
-
/// GitHub personal access token (overrides GITHUB_TOKEN and dotfile)
|
|
67
|
-
#[arg(long)]
|
|
68
|
-
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,
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
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>),
|
|
76
|
+
|
|
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() {
|
|
72
91
|
let cli = Cli::parse();
|
|
73
|
-
let
|
|
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);
|
|
95
|
+
}
|
|
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);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
74
112
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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()),
|
|
78
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
|
+
}
|
|
79
131
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
132
|
+
#[cfg(test)]
|
|
133
|
+
mod tests {
|
|
134
|
+
use super::*;
|
|
83
135
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|
|
89
150
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
163
|
}
|
|
96
|
-
}
|
|
164
|
+
}
|
|
97
165
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
let
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
))
|
|
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
|
+
}
|
|
111
181
|
}
|
|
112
|
-
|
|
113
|
-
|
|
182
|
+
}
|
|
183
|
+
|
|
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
|
+
}
|
|
114
201
|
|
|
115
|
-
|
|
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
|
+
}
|
|
116
235
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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"),
|
|
122
252
|
}
|
|
123
|
-
None => println!("{output}"),
|
|
124
253
|
}
|
|
125
254
|
|
|
126
|
-
|
|
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
|
+
}
|
|
127
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
|
+
}
|