@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
|
@@ -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
|
+
}
|