@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.
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 +7 -7
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +109 -72
  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 +641 -42
  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 +230 -91
  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 +179 -24
  71. package/tests/integration.rs +406 -26
  72. 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 anyhow::{Result, anyhow};
7
- use clap::{Parser, ValueEnum};
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 config::Config;
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 OutputFormat {
16
- Json,
17
- Yaml,
17
+ enum CompletionShell {
18
+ Bash,
19
+ Zsh,
20
+ Fish,
21
+ Powershell,
22
+ Elvish,
18
23
  }
19
24
 
20
- #[derive(Debug, Clone, ValueEnum)]
21
- enum SourceKind {
22
- GithubIssues,
23
- GithubPrs,
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 GitHub issue conversations",
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
- /// Full GitHub search query (same syntax as the web UI search bar)
34
- /// e.g. "is:issue state:closed Event repo:owner/repo"
35
- #[arg(short = 'q', long)]
36
- query: Option<String>,
37
-
38
- /// Shorthand for adding "repo:owner/repo" to the query
39
- #[arg(long)]
40
- repo: Option<String>,
41
-
42
- /// Shorthand for adding "state:open|closed" to the query
43
- #[arg(long)]
44
- state: Option<String>,
45
-
46
- /// Shorthand for comma-separated labels, e.g. "bug,enhancement"
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
- fn main() -> Result<()> {
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 mut cfg = Config::load()?;
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
- // CLI token overrides everything
76
- if let Some(t) = cli.token {
77
- cfg.token = Some(t);
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
- // Override repo/state from CLI if provided
81
- let repo = cli.repo.or(cfg.repo.clone());
82
- let state = cli.state.or(cfg.state.clone());
132
+ #[cfg(test)]
133
+ mod tests {
134
+ use super::*;
83
135
 
84
- // Build the formatter
85
- let formatter: Box<dyn Formatter> = match cli.format {
86
- OutputFormat::Json => Box::new(JsonFormatter),
87
- OutputFormat::Yaml => Box::new(YamlFormatter),
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
- // Build the source
91
- let source: Box<dyn Source> = match cli.source {
92
- SourceKind::GithubIssues => Box::new(GitHubIssues::new()?),
93
- SourceKind::GithubPrs => {
94
- return Err(anyhow!("github-prs source is not yet implemented"));
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
- let conversations = if let Some(issue_id) = cli.issue {
99
- // Single-issue mode
100
- let r = repo
101
- .as_deref()
102
- .ok_or_else(|| anyhow!("--repo is required when using --issue"))?;
103
- vec![source.fetch_one(r, issue_id)?]
104
- } else {
105
- // Search mode
106
- let query = Query::build(cli.query, repo, state, cli.labels, cfg.per_page, cfg.token);
107
- if query.raw.trim().is_empty() {
108
- return Err(anyhow!(
109
- "No query specified. Use -q or provide --repo/--state/--labels."
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
- source.fetch(&query)?
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
- let output = formatter.format(&conversations)?;
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
- match cli.output {
118
- Some(path) => {
119
- let mut file = std::fs::File::create(&path)?;
120
- file.write_all(output.as_bytes())?;
121
- eprintln!("Wrote {} conversations to {path}", conversations.len());
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
- Ok(())
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: u64,
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(&current_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
+ &params,
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
+ }