@mbe24/99problems 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
  2. package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
  3. package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  5. package/.github/dependabot.yml +20 -0
  6. package/.github/scripts/publish.js +1 -1
  7. package/.github/workflows/ci.yml +32 -6
  8. package/.github/workflows/man-drift.yml +26 -0
  9. package/.github/workflows/release.yml +49 -9
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +107 -85
  14. package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
  15. package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
  16. package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
  17. package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
  18. package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
  19. package/docs/man/99problems-completions.1 +31 -0
  20. package/docs/man/99problems-config.1 +50 -0
  21. package/docs/man/99problems-get.1 +114 -0
  22. package/docs/man/99problems-help.1 +25 -0
  23. package/docs/man/99problems-man.1 +32 -0
  24. package/docs/man/99problems.1 +52 -0
  25. package/npm/install.js +90 -3
  26. package/package.json +7 -7
  27. package/rust-toolchain.toml +4 -0
  28. package/src/cmd/config/key.rs +126 -0
  29. package/src/cmd/config/mod.rs +218 -0
  30. package/src/cmd/config/render.rs +33 -0
  31. package/src/cmd/config/store.rs +318 -0
  32. package/src/cmd/config/write.rs +173 -0
  33. package/src/cmd/get.rs +658 -0
  34. package/src/cmd/man.rs +117 -0
  35. package/src/cmd/mod.rs +3 -0
  36. package/src/config.rs +618 -118
  37. package/src/error.rs +254 -0
  38. package/src/format/json.rs +59 -18
  39. package/src/format/jsonl.rs +52 -0
  40. package/src/format/mod.rs +25 -3
  41. package/src/format/text.rs +73 -0
  42. package/src/format/yaml.rs +64 -15
  43. package/src/lib.rs +1 -0
  44. package/src/logging.rs +54 -0
  45. package/src/main.rs +225 -138
  46. package/src/model.rs +9 -1
  47. package/src/source/bitbucket/cloud/api.rs +67 -0
  48. package/src/source/bitbucket/cloud/mod.rs +178 -0
  49. package/src/source/bitbucket/cloud/model.rs +211 -0
  50. package/src/source/bitbucket/datacenter/api.rs +74 -0
  51. package/src/source/bitbucket/datacenter/mod.rs +181 -0
  52. package/src/source/bitbucket/datacenter/model.rs +327 -0
  53. package/src/source/bitbucket/mod.rs +90 -0
  54. package/src/source/bitbucket/query.rs +169 -0
  55. package/src/source/bitbucket/shared/auth.rs +54 -0
  56. package/src/source/bitbucket/shared/http.rs +59 -0
  57. package/src/source/bitbucket/shared/mod.rs +5 -0
  58. package/src/source/github/api.rs +128 -0
  59. package/src/source/github/mod.rs +191 -0
  60. package/src/source/github/model.rs +84 -0
  61. package/src/source/github/query.rs +50 -0
  62. package/src/source/gitlab/api.rs +282 -0
  63. package/src/source/gitlab/mod.rs +225 -0
  64. package/src/source/gitlab/model.rs +102 -0
  65. package/src/source/gitlab/query.rs +177 -0
  66. package/src/source/jira/api.rs +222 -0
  67. package/src/source/jira/mod.rs +161 -0
  68. package/src/source/jira/model.rs +99 -0
  69. package/src/source/jira/query.rs +153 -0
  70. package/src/source/mod.rs +65 -7
  71. package/tests/integration.rs +404 -33
  72. package/src/source/github_issues.rs +0 -227
@@ -0,0 +1,128 @@
1
+ use anyhow::Result;
2
+ use reqwest::blocking::{RequestBuilder, Response};
3
+ use serde::Deserialize;
4
+ use tracing::{debug, trace};
5
+
6
+ use super::model::{
7
+ ConversationSeed, IssueCommentItem, ReviewCommentItem, map_issue_comment, map_review_comment,
8
+ };
9
+ use super::{GITHUB_API_BASE, GITHUB_API_VERSION, GitHubSource, PAGE_SIZE};
10
+ use crate::error::{AppError, app_error_from_decode, app_error_from_reqwest};
11
+ use crate::model::{Comment, Conversation};
12
+ use crate::source::FetchRequest;
13
+
14
+ impl GitHubSource {
15
+ pub(super) fn apply_auth(req: RequestBuilder, token: Option<&str>) -> RequestBuilder {
16
+ match token {
17
+ Some(t) => req
18
+ .header("Authorization", format!("Bearer {t}"))
19
+ .header("X-GitHub-Api-Version", GITHUB_API_VERSION),
20
+ None => req,
21
+ }
22
+ }
23
+
24
+ pub(super) fn bounded_per_page(per_page: u32) -> u32 {
25
+ per_page.clamp(1, PAGE_SIZE)
26
+ }
27
+
28
+ pub(super) fn send(req: RequestBuilder, operation: &str) -> Result<Response> {
29
+ req.send()
30
+ .map_err(|err| app_error_from_reqwest("GitHub", operation, &err).into())
31
+ }
32
+
33
+ pub(super) fn get_pages<T: for<'de> Deserialize<'de>>(
34
+ &self,
35
+ url: &str,
36
+ token: Option<&str>,
37
+ per_page: u32,
38
+ ) -> Result<Vec<T>> {
39
+ let mut results = vec![];
40
+ let mut page = 1u32;
41
+ let per_page = Self::bounded_per_page(per_page);
42
+
43
+ loop {
44
+ debug!(url = %url, page, per_page, "fetching GitHub page");
45
+ let req = self.client.get(url).query(&[
46
+ ("per_page", per_page.to_string()),
47
+ ("page", page.to_string()),
48
+ ]);
49
+ let req = Self::apply_auth(req, token);
50
+ let resp = Self::send(req, "page fetch")?;
51
+
52
+ if !resp.status().is_success() {
53
+ let status = resp.status();
54
+ let body = resp
55
+ .text()
56
+ .map_err(|err| app_error_from_reqwest("GitHub", "error body read", &err))?;
57
+ return Err(AppError::from_http("GitHub", "page fetch", status, &body).into());
58
+ }
59
+
60
+ let has_next = resp
61
+ .headers()
62
+ .get("link")
63
+ .and_then(|v| v.to_str().ok())
64
+ .is_some_and(|l| l.contains(r#"rel="next""#));
65
+
66
+ let items: Vec<T> = resp
67
+ .json()
68
+ .map_err(|err| app_error_from_decode("GitHub", "page fetch", err))?;
69
+ trace!(count = items.len(), page, "decoded GitHub page");
70
+ let done = items.is_empty() || !has_next;
71
+ results.extend(items);
72
+ if done {
73
+ break;
74
+ }
75
+ page += 1;
76
+ }
77
+
78
+ Ok(results)
79
+ }
80
+
81
+ pub(super) fn fetch_issue_comments(
82
+ &self,
83
+ repo: &str,
84
+ id: u64,
85
+ req: &FetchRequest,
86
+ ) -> Result<Vec<Comment>> {
87
+ let comments_url = format!("{GITHUB_API_BASE}/repos/{repo}/issues/{id}/comments");
88
+ let raw_comments: Vec<IssueCommentItem> =
89
+ self.get_pages(&comments_url, req.token.as_deref(), req.per_page)?;
90
+ Ok(raw_comments.into_iter().map(map_issue_comment).collect())
91
+ }
92
+
93
+ pub(super) fn fetch_review_comments(
94
+ &self,
95
+ repo: &str,
96
+ id: u64,
97
+ req: &FetchRequest,
98
+ ) -> Result<Vec<Comment>> {
99
+ let comments_url = format!("{GITHUB_API_BASE}/repos/{repo}/pulls/{id}/comments");
100
+ let raw_comments: Vec<ReviewCommentItem> =
101
+ self.get_pages(&comments_url, req.token.as_deref(), req.per_page)?;
102
+ Ok(raw_comments.into_iter().map(map_review_comment).collect())
103
+ }
104
+
105
+ pub(super) fn fetch_conversation(
106
+ &self,
107
+ repo: &str,
108
+ item: ConversationSeed,
109
+ req: &FetchRequest,
110
+ ) -> Result<Conversation> {
111
+ let mut comments = Vec::new();
112
+ if req.include_comments {
113
+ comments = self.fetch_issue_comments(repo, item.id, req)?;
114
+ if item.is_pr && req.include_review_comments {
115
+ comments.extend(self.fetch_review_comments(repo, item.id, req)?);
116
+ comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
117
+ }
118
+ }
119
+
120
+ Ok(Conversation {
121
+ id: item.id.to_string(),
122
+ title: item.title,
123
+ state: item.state,
124
+ body: item.body,
125
+ comments,
126
+ })
127
+ }
128
+ }
@@ -0,0 +1,191 @@
1
+ use anyhow::Result;
2
+ use reqwest::blocking::Client;
3
+ use tracing::{debug, trace, warn};
4
+
5
+ use super::{ContentKind, FetchRequest, FetchTarget, Source};
6
+ use crate::error::{AppError, app_error_from_decode, app_error_from_reqwest};
7
+ use crate::model::Conversation;
8
+
9
+ mod api;
10
+ mod model;
11
+ mod query;
12
+
13
+ use model::{ConversationSeed, IssueItem, SearchResponse};
14
+ use query::{extract_repo, repo_from_repository_url};
15
+
16
+ pub(super) const GITHUB_API_BASE: &str = "https://api.github.com";
17
+ pub(super) const GITHUB_API_VERSION: &str = "2022-11-28";
18
+ pub(super) const PAGE_SIZE: u32 = 100;
19
+
20
+ pub struct GitHubSource {
21
+ client: Client,
22
+ }
23
+
24
+ impl GitHubSource {
25
+ /// Create a GitHub source client.
26
+ ///
27
+ /// # Errors
28
+ ///
29
+ /// Returns an error if the HTTP client cannot be constructed.
30
+ pub fn new() -> Result<Self> {
31
+ let client = Client::builder()
32
+ .user_agent(concat!("99problems-cli/", env!("CARGO_PKG_VERSION")))
33
+ .build()?;
34
+ Ok(Self { client })
35
+ }
36
+
37
+ fn search_stream(
38
+ &self,
39
+ req: &FetchRequest,
40
+ raw_query: &str,
41
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
42
+ ) -> Result<usize> {
43
+ let search_url = format!("{GITHUB_API_BASE}/search/issues");
44
+ let mut page = 1u32;
45
+ let mut emitted = 0usize;
46
+ let per_page = Self::bounded_per_page(req.per_page);
47
+ let repo_from_query = extract_repo(raw_query);
48
+
49
+ loop {
50
+ debug!(page, per_page, "fetching GitHub search page");
51
+ let req_http = self.client.get(&search_url).query(&[
52
+ ("q", raw_query),
53
+ ("per_page", &per_page.to_string()),
54
+ ("page", &page.to_string()),
55
+ ]);
56
+ let req_http = Self::apply_auth(req_http, req.token.as_deref());
57
+ let resp = Self::send(req_http, "search")?;
58
+
59
+ if !resp.status().is_success() {
60
+ let status = resp.status();
61
+ let body = resp
62
+ .text()
63
+ .map_err(|err| app_error_from_reqwest("GitHub", "error body read", &err))?;
64
+ return Err(AppError::from_http("GitHub", "search", status, &body).into());
65
+ }
66
+
67
+ let search: SearchResponse = resp
68
+ .json()
69
+ .map_err(|err| app_error_from_decode("GitHub", "search", err))?;
70
+ trace!(
71
+ count = search.items.len(),
72
+ page, "decoded GitHub search page"
73
+ );
74
+ let done = search.items.len() < per_page as usize;
75
+ for item in search.items {
76
+ let repo = item
77
+ .repository_url
78
+ .as_deref()
79
+ .and_then(repo_from_repository_url)
80
+ .or_else(|| repo_from_query.clone())
81
+ .ok_or_else(|| {
82
+ AppError::usage(format!(
83
+ "Could not determine repo for item #{}. Include repo:owner/name in query.",
84
+ item.number
85
+ ))
86
+ })?;
87
+
88
+ let conversation = self.fetch_conversation(
89
+ &repo,
90
+ ConversationSeed {
91
+ id: item.number,
92
+ title: item.title,
93
+ state: item.state,
94
+ body: item.body,
95
+ is_pr: item.pull_request.is_some(),
96
+ },
97
+ req,
98
+ )?;
99
+ emit(conversation)?;
100
+ emitted += 1;
101
+ }
102
+ if done {
103
+ break;
104
+ }
105
+ page += 1;
106
+ }
107
+
108
+ Ok(emitted)
109
+ }
110
+
111
+ fn fetch_by_id_stream(
112
+ &self,
113
+ req: &FetchRequest,
114
+ repo: &str,
115
+ id: &str,
116
+ kind: ContentKind,
117
+ allow_fallback_to_pr: bool,
118
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
119
+ ) -> Result<usize> {
120
+ let issue_id = id.parse::<u64>().map_err(|_| {
121
+ AppError::usage(format!("GitHub expects a numeric issue/PR id, got '{id}'."))
122
+ })?;
123
+ let issue_url = format!("{GITHUB_API_BASE}/repos/{repo}/issues/{issue_id}");
124
+ let request = Self::apply_auth(self.client.get(&issue_url), req.token.as_deref());
125
+ let resp = Self::send(request, "issue fetch")?;
126
+ if !resp.status().is_success() {
127
+ let status = resp.status();
128
+ let body = resp
129
+ .text()
130
+ .map_err(|err| app_error_from_reqwest("GitHub", "error body read", &err))?;
131
+ return Err(AppError::from_http("GitHub", "issue fetch", status, &body).into());
132
+ }
133
+ let issue: IssueItem = resp
134
+ .json()
135
+ .map_err(|err| app_error_from_decode("GitHub", "issue fetch", err))?;
136
+ let is_pr = issue.pull_request.is_some();
137
+
138
+ match kind {
139
+ ContentKind::Issue if is_pr && !allow_fallback_to_pr => {
140
+ return Err(AppError::usage(format!(
141
+ "ID {issue_id} in repo {repo} is a pull request. Use --type pr or omit --type."
142
+ ))
143
+ .into());
144
+ }
145
+ ContentKind::Issue if is_pr && allow_fallback_to_pr => {
146
+ warn!(
147
+ "Warning: --id defaulted to issue, but found PR #{issue_id}; use --type pr for clarity."
148
+ );
149
+ }
150
+ ContentKind::Pr if !is_pr => {
151
+ return Err(AppError::usage(format!(
152
+ "ID {issue_id} in repo {repo} is an issue, not a pull request."
153
+ ))
154
+ .into());
155
+ }
156
+ _ => {}
157
+ }
158
+
159
+ let conversation = self.fetch_conversation(
160
+ repo,
161
+ ConversationSeed {
162
+ id: issue.number,
163
+ title: issue.title,
164
+ state: issue.state,
165
+ body: issue.body,
166
+ is_pr,
167
+ },
168
+ req,
169
+ )?;
170
+ emit(conversation)?;
171
+ Ok(1)
172
+ }
173
+ }
174
+
175
+ impl Source for GitHubSource {
176
+ fn fetch_stream(
177
+ &self,
178
+ req: &FetchRequest,
179
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
180
+ ) -> Result<usize> {
181
+ match &req.target {
182
+ FetchTarget::Search { raw_query } => self.search_stream(req, raw_query, emit),
183
+ FetchTarget::Id {
184
+ repo,
185
+ id,
186
+ kind,
187
+ allow_fallback_to_pr,
188
+ } => self.fetch_by_id_stream(req, repo, id, *kind, *allow_fallback_to_pr, emit),
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,84 @@
1
+ use serde::Deserialize;
2
+
3
+ use crate::model::Comment;
4
+
5
+ #[derive(Deserialize)]
6
+ pub(super) struct SearchResponse {
7
+ pub(super) items: Vec<SearchItem>,
8
+ }
9
+
10
+ #[derive(Deserialize)]
11
+ pub(super) struct SearchItem {
12
+ pub(super) number: u64,
13
+ pub(super) title: String,
14
+ pub(super) state: String,
15
+ pub(super) body: Option<String>,
16
+ pub(super) repository_url: Option<String>,
17
+ pub(super) pull_request: Option<PullRequestMarker>,
18
+ }
19
+
20
+ #[derive(Deserialize)]
21
+ pub(super) struct IssueItem {
22
+ pub(super) number: u64,
23
+ pub(super) title: String,
24
+ pub(super) state: String,
25
+ pub(super) body: Option<String>,
26
+ pub(super) pull_request: Option<PullRequestMarker>,
27
+ }
28
+
29
+ #[derive(Deserialize)]
30
+ pub(super) struct PullRequestMarker {}
31
+
32
+ #[derive(Deserialize)]
33
+ pub(super) struct IssueCommentItem {
34
+ pub(super) user: Option<UserItem>,
35
+ pub(super) created_at: String,
36
+ pub(super) body: Option<String>,
37
+ }
38
+
39
+ #[derive(Deserialize)]
40
+ pub(super) struct ReviewCommentItem {
41
+ pub(super) user: Option<UserItem>,
42
+ pub(super) created_at: String,
43
+ pub(super) body: Option<String>,
44
+ pub(super) path: Option<String>,
45
+ pub(super) line: Option<u64>,
46
+ pub(super) side: Option<String>,
47
+ }
48
+
49
+ #[derive(Deserialize)]
50
+ pub(super) struct UserItem {
51
+ pub(super) login: String,
52
+ }
53
+
54
+ pub(super) struct ConversationSeed {
55
+ pub(super) id: u64,
56
+ pub(super) title: String,
57
+ pub(super) state: String,
58
+ pub(super) body: Option<String>,
59
+ pub(super) is_pr: bool,
60
+ }
61
+
62
+ pub(super) fn map_issue_comment(c: IssueCommentItem) -> Comment {
63
+ Comment {
64
+ author: c.user.map(|u| u.login),
65
+ created_at: c.created_at,
66
+ body: c.body,
67
+ kind: Some("issue_comment".into()),
68
+ review_path: None,
69
+ review_line: None,
70
+ review_side: None,
71
+ }
72
+ }
73
+
74
+ pub(super) fn map_review_comment(c: ReviewCommentItem) -> Comment {
75
+ Comment {
76
+ author: c.user.map(|u| u.login),
77
+ created_at: c.created_at,
78
+ body: c.body,
79
+ kind: Some("review_comment".into()),
80
+ review_path: c.path,
81
+ review_line: c.line,
82
+ review_side: c.side,
83
+ }
84
+ }
@@ -0,0 +1,50 @@
1
+ use super::GITHUB_API_BASE;
2
+
3
+ /// Extract `owner/repo` from a query string containing `repo:owner/repo`.
4
+ #[must_use]
5
+ pub fn extract_repo(query: &str) -> Option<String> {
6
+ query
7
+ .split_whitespace()
8
+ .find(|t| t.starts_with("repo:"))
9
+ .map(|t| t.trim_start_matches("repo:").to_string())
10
+ }
11
+
12
+ pub(super) fn repo_from_repository_url(url: &str) -> Option<String> {
13
+ let prefix = format!("{GITHUB_API_BASE}/repos/");
14
+ url.strip_prefix(&prefix)
15
+ .map(std::string::ToString::to_string)
16
+ }
17
+
18
+ #[cfg(test)]
19
+ mod tests {
20
+ use super::*;
21
+
22
+ #[test]
23
+ fn extract_repo_finds_token() {
24
+ assert_eq!(
25
+ extract_repo("is:issue state:closed repo:owner/repo Event"),
26
+ Some("owner/repo".into())
27
+ );
28
+ }
29
+
30
+ #[test]
31
+ fn extract_repo_returns_none_when_absent() {
32
+ assert_eq!(extract_repo("is:issue state:closed Event"), None);
33
+ }
34
+
35
+ #[test]
36
+ fn repo_from_repository_url_parses_repo() {
37
+ assert_eq!(
38
+ repo_from_repository_url("https://api.github.com/repos/owner/repo"),
39
+ Some("owner/repo".into())
40
+ );
41
+ }
42
+
43
+ #[test]
44
+ fn repo_from_repository_url_returns_none_for_non_github_api_url() {
45
+ assert_eq!(
46
+ repo_from_repository_url("https://example.com/repos/owner/repo"),
47
+ None
48
+ );
49
+ }
50
+ }