@mbe24/99problems 0.1.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/.githooks/pre-commit +22 -0
- package/.github/logo.svg +12 -0
- package/.github/scripts/publish.js +93 -0
- package/.github/social-preview.svg +82 -0
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/release.yml +115 -0
- package/CONTRIBUTING.md +74 -0
- package/Cargo.lock +1758 -0
- package/Cargo.toml +27 -0
- package/LICENSE +201 -0
- package/README.md +124 -0
- 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/npm/bin/99problems.js +42 -0
- package/npm/install.js +26 -0
- package/package.json +23 -0
- package/src/config.rs +101 -0
- package/src/format/json.rs +46 -0
- package/src/format/mod.rs +10 -0
- package/src/format/yaml.rs +44 -0
- package/src/lib.rs +4 -0
- package/src/main.rs +127 -0
- package/src/model.rs +17 -0
- package/src/source/github_issues.rs +232 -0
- package/src/source/mod.rs +110 -0
- package/tests/integration.rs +58 -0
package/src/main.rs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
mod config;
|
|
2
|
+
mod format;
|
|
3
|
+
mod model;
|
|
4
|
+
mod source;
|
|
5
|
+
|
|
6
|
+
use anyhow::{Result, anyhow};
|
|
7
|
+
use clap::{Parser, ValueEnum};
|
|
8
|
+
use std::io::Write;
|
|
9
|
+
|
|
10
|
+
use config::Config;
|
|
11
|
+
use format::{Formatter, json::JsonFormatter, yaml::YamlFormatter};
|
|
12
|
+
use source::{Query, Source, github_issues::GitHubIssues};
|
|
13
|
+
|
|
14
|
+
#[derive(Debug, Clone, ValueEnum)]
|
|
15
|
+
enum OutputFormat {
|
|
16
|
+
Json,
|
|
17
|
+
Yaml,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[derive(Debug, Clone, ValueEnum)]
|
|
21
|
+
enum SourceKind {
|
|
22
|
+
GithubIssues,
|
|
23
|
+
GithubPrs,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[derive(Parser, Debug)]
|
|
27
|
+
#[command(
|
|
28
|
+
name = "99problems",
|
|
29
|
+
about = "Fetch GitHub issue conversations",
|
|
30
|
+
version
|
|
31
|
+
)]
|
|
32
|
+
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>,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn main() -> Result<()> {
|
|
72
|
+
let cli = Cli::parse();
|
|
73
|
+
let mut cfg = Config::load()?;
|
|
74
|
+
|
|
75
|
+
// CLI token overrides everything
|
|
76
|
+
if let Some(t) = cli.token {
|
|
77
|
+
cfg.token = Some(t);
|
|
78
|
+
}
|
|
79
|
+
|
|
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());
|
|
83
|
+
|
|
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
|
+
};
|
|
89
|
+
|
|
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"));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
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
|
+
));
|
|
111
|
+
}
|
|
112
|
+
source.fetch(&query)?
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let output = formatter.format(&conversations)?;
|
|
116
|
+
|
|
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());
|
|
122
|
+
}
|
|
123
|
+
None => println!("{output}"),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Ok(())
|
|
127
|
+
}
|
package/src/model.rs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
|
|
3
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
4
|
+
pub struct Conversation {
|
|
5
|
+
pub id: u64,
|
|
6
|
+
pub title: String,
|
|
7
|
+
pub state: String,
|
|
8
|
+
pub body: Option<String>,
|
|
9
|
+
pub comments: Vec<Comment>,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
13
|
+
pub struct Comment {
|
|
14
|
+
pub author: Option<String>,
|
|
15
|
+
pub created_at: String,
|
|
16
|
+
pub body: Option<String>,
|
|
17
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
use anyhow::{Result, anyhow};
|
|
2
|
+
use reqwest::blocking::Client;
|
|
3
|
+
use serde::Deserialize;
|
|
4
|
+
|
|
5
|
+
use super::{Query, Source};
|
|
6
|
+
use crate::model::{Comment, Conversation};
|
|
7
|
+
|
|
8
|
+
pub struct GitHubIssues {
|
|
9
|
+
client: Client,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl GitHubIssues {
|
|
13
|
+
pub fn new() -> Result<Self> {
|
|
14
|
+
let client = Client::builder()
|
|
15
|
+
.user_agent("99problems-cli/0.1.0")
|
|
16
|
+
.build()?;
|
|
17
|
+
Ok(Self { client })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn auth_header(token: &Option<String>) -> Option<String> {
|
|
21
|
+
token.as_ref().map(|t| format!("Bearer {t}"))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn get_pages<T: for<'de> Deserialize<'de>>(
|
|
25
|
+
&self,
|
|
26
|
+
url: &str,
|
|
27
|
+
token: &Option<String>,
|
|
28
|
+
per_page: u32,
|
|
29
|
+
) -> Result<Vec<T>> {
|
|
30
|
+
let mut results = vec![];
|
|
31
|
+
let mut page = 1u32;
|
|
32
|
+
|
|
33
|
+
loop {
|
|
34
|
+
let mut req = self.client.get(url).query(&[
|
|
35
|
+
("per_page", per_page.to_string()),
|
|
36
|
+
("page", page.to_string()),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
if let Some(auth) = Self::auth_header(token) {
|
|
40
|
+
req = req
|
|
41
|
+
.header("Authorization", auth)
|
|
42
|
+
.header("X-GitHub-Api-Version", "2022-11-28");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let resp = req.send()?;
|
|
46
|
+
|
|
47
|
+
if !resp.status().is_success() {
|
|
48
|
+
return Err(anyhow!(
|
|
49
|
+
"GitHub API error {}: {}",
|
|
50
|
+
resp.status(),
|
|
51
|
+
resp.text()?
|
|
52
|
+
));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let has_next = resp
|
|
56
|
+
.headers()
|
|
57
|
+
.get("link")
|
|
58
|
+
.and_then(|v| v.to_str().ok())
|
|
59
|
+
.map(|l| l.contains(r#"rel="next""#))
|
|
60
|
+
.unwrap_or(false);
|
|
61
|
+
|
|
62
|
+
let items: Vec<T> = resp.json()?;
|
|
63
|
+
let done = items.is_empty() || !has_next;
|
|
64
|
+
results.extend(items);
|
|
65
|
+
if done {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
page += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Ok(results)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- GitHub API response shapes ---
|
|
76
|
+
|
|
77
|
+
#[derive(Deserialize)]
|
|
78
|
+
struct SearchResponse {
|
|
79
|
+
items: Vec<IssueItem>,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[derive(Deserialize)]
|
|
83
|
+
struct IssueItem {
|
|
84
|
+
number: u64,
|
|
85
|
+
title: String,
|
|
86
|
+
state: String,
|
|
87
|
+
body: Option<String>,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Deserialize)]
|
|
91
|
+
struct CommentItem {
|
|
92
|
+
user: Option<UserItem>,
|
|
93
|
+
created_at: String,
|
|
94
|
+
body: Option<String>,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[derive(Deserialize)]
|
|
98
|
+
struct UserItem {
|
|
99
|
+
login: String,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
impl Source for GitHubIssues {
|
|
103
|
+
fn fetch(&self, query: &Query) -> Result<Vec<Conversation>> {
|
|
104
|
+
let search_url = "https://api.github.com/search/issues";
|
|
105
|
+
let mut page = 1u32;
|
|
106
|
+
let mut all_issues: Vec<IssueItem> = vec![];
|
|
107
|
+
|
|
108
|
+
loop {
|
|
109
|
+
let mut req = self.client.get(search_url).query(&[
|
|
110
|
+
("q", query.raw.as_str()),
|
|
111
|
+
("per_page", "100"),
|
|
112
|
+
("page", &page.to_string()),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
if let Some(auth) = Self::auth_header(&query.token) {
|
|
116
|
+
req = req
|
|
117
|
+
.header("Authorization", auth)
|
|
118
|
+
.header("X-GitHub-Api-Version", "2022-11-28");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let resp = req.send()?;
|
|
122
|
+
if !resp.status().is_success() {
|
|
123
|
+
return Err(anyhow!(
|
|
124
|
+
"GitHub search error {}: {}",
|
|
125
|
+
resp.status(),
|
|
126
|
+
resp.text()?
|
|
127
|
+
));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let search: SearchResponse = resp.json()?;
|
|
131
|
+
let done = search.items.len() < 100;
|
|
132
|
+
all_issues.extend(search.items);
|
|
133
|
+
if done {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
page += 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Determine repo from query for comment fetching
|
|
140
|
+
let repo = extract_repo(&query.raw).ok_or_else(|| {
|
|
141
|
+
anyhow!("No repo: found in query. Use --repo or include 'repo:owner/name' in -q")
|
|
142
|
+
})?;
|
|
143
|
+
|
|
144
|
+
let mut conversations = vec![];
|
|
145
|
+
for issue in all_issues {
|
|
146
|
+
let comments_url = format!(
|
|
147
|
+
"https://api.github.com/repos/{repo}/issues/{}/comments",
|
|
148
|
+
issue.number
|
|
149
|
+
);
|
|
150
|
+
let raw_comments: Vec<CommentItem> =
|
|
151
|
+
self.get_pages(&comments_url, &query.token, 100)?;
|
|
152
|
+
|
|
153
|
+
conversations.push(Conversation {
|
|
154
|
+
id: issue.number,
|
|
155
|
+
title: issue.title,
|
|
156
|
+
state: issue.state,
|
|
157
|
+
body: issue.body,
|
|
158
|
+
comments: raw_comments
|
|
159
|
+
.into_iter()
|
|
160
|
+
.map(|c| Comment {
|
|
161
|
+
author: c.user.map(|u| u.login),
|
|
162
|
+
created_at: c.created_at,
|
|
163
|
+
body: c.body,
|
|
164
|
+
})
|
|
165
|
+
.collect(),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
Ok(conversations)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn fetch_one(&self, repo: &str, issue_id: u64) -> Result<Conversation> {
|
|
173
|
+
let issue_url = format!("https://api.github.com/repos/{repo}/issues/{issue_id}");
|
|
174
|
+
let req = self.client.get(&issue_url);
|
|
175
|
+
// token is not stored on struct, so we pass None here — callers set it via Query
|
|
176
|
+
// For fetch_one we skip auth (public repos). Callers who need auth should use fetch().
|
|
177
|
+
let resp = req.send()?;
|
|
178
|
+
if !resp.status().is_success() {
|
|
179
|
+
return Err(anyhow!(
|
|
180
|
+
"GitHub issue error {}: {}",
|
|
181
|
+
resp.status(),
|
|
182
|
+
resp.text()?
|
|
183
|
+
));
|
|
184
|
+
}
|
|
185
|
+
let issue: IssueItem = resp.json()?;
|
|
186
|
+
|
|
187
|
+
let comments_url =
|
|
188
|
+
format!("https://api.github.com/repos/{repo}/issues/{issue_id}/comments");
|
|
189
|
+
let raw_comments: Vec<CommentItem> = self.get_pages(&comments_url, &None, 100)?;
|
|
190
|
+
|
|
191
|
+
Ok(Conversation {
|
|
192
|
+
id: issue.number,
|
|
193
|
+
title: issue.title,
|
|
194
|
+
state: issue.state,
|
|
195
|
+
body: issue.body,
|
|
196
|
+
comments: raw_comments
|
|
197
|
+
.into_iter()
|
|
198
|
+
.map(|c| Comment {
|
|
199
|
+
author: c.user.map(|u| u.login),
|
|
200
|
+
created_at: c.created_at,
|
|
201
|
+
body: c.body,
|
|
202
|
+
})
|
|
203
|
+
.collect(),
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Extract `owner/repo` from a query string containing `repo:owner/repo`.
|
|
209
|
+
pub fn extract_repo(query: &str) -> Option<String> {
|
|
210
|
+
query
|
|
211
|
+
.split_whitespace()
|
|
212
|
+
.find(|t| t.starts_with("repo:"))
|
|
213
|
+
.map(|t| t.trim_start_matches("repo:").to_string())
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[cfg(test)]
|
|
217
|
+
mod tests {
|
|
218
|
+
use super::*;
|
|
219
|
+
|
|
220
|
+
#[test]
|
|
221
|
+
fn extract_repo_finds_token() {
|
|
222
|
+
assert_eq!(
|
|
223
|
+
extract_repo("is:issue state:closed repo:owner/repo Event"),
|
|
224
|
+
Some("owner/repo".into())
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn extract_repo_returns_none_when_absent() {
|
|
230
|
+
assert_eq!(extract_repo("is:issue state:closed Event"), None);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
use crate::model::Conversation;
|
|
2
|
+
use anyhow::Result;
|
|
3
|
+
|
|
4
|
+
pub mod github_issues;
|
|
5
|
+
|
|
6
|
+
/// A pluggable data source that fetches issue conversations.
|
|
7
|
+
pub trait Source {
|
|
8
|
+
fn fetch(&self, query: &Query) -> Result<Vec<Conversation>>;
|
|
9
|
+
fn fetch_one(&self, repo: &str, issue_id: u64) -> Result<Conversation>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// Parsed search parameters passed to a Source.
|
|
13
|
+
#[derive(Debug, Default, Clone)]
|
|
14
|
+
#[allow(dead_code)]
|
|
15
|
+
pub struct Query {
|
|
16
|
+
/// Full raw query string (GitHub search syntax), e.g. "is:issue state:closed Event repo:owner/repo"
|
|
17
|
+
pub raw: String,
|
|
18
|
+
pub per_page: u32,
|
|
19
|
+
pub token: Option<String>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl Query {
|
|
23
|
+
/// Build a query by merging a raw string with convenience shorthands.
|
|
24
|
+
/// Shorthands are only appended if their qualifier isn't already present.
|
|
25
|
+
pub fn build(
|
|
26
|
+
raw: Option<String>,
|
|
27
|
+
repo: Option<String>,
|
|
28
|
+
state: Option<String>,
|
|
29
|
+
labels: Option<String>,
|
|
30
|
+
per_page: u32,
|
|
31
|
+
token: Option<String>,
|
|
32
|
+
) -> Self {
|
|
33
|
+
let mut parts: Vec<String> = vec![];
|
|
34
|
+
|
|
35
|
+
if let Some(r) = raw {
|
|
36
|
+
parts.push(r);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if let Some(repo) = repo
|
|
40
|
+
&& !parts.iter().any(|p| p.contains("repo:"))
|
|
41
|
+
{
|
|
42
|
+
parts.push(format!("repo:{repo}"));
|
|
43
|
+
}
|
|
44
|
+
if let Some(state) = state
|
|
45
|
+
&& !parts.iter().any(|p| p.contains("state:"))
|
|
46
|
+
{
|
|
47
|
+
parts.push(format!("state:{state}"));
|
|
48
|
+
}
|
|
49
|
+
if let Some(labels) = labels {
|
|
50
|
+
for label in labels.split(',') {
|
|
51
|
+
let label = label.trim();
|
|
52
|
+
if !label.is_empty() {
|
|
53
|
+
parts.push(format!("label:{label}"));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
Query {
|
|
59
|
+
raw: parts.join(" "),
|
|
60
|
+
per_page,
|
|
61
|
+
token,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[cfg(test)]
|
|
67
|
+
mod tests {
|
|
68
|
+
use super::*;
|
|
69
|
+
|
|
70
|
+
#[test]
|
|
71
|
+
fn build_query_appends_repo_and_state() {
|
|
72
|
+
let q = Query::build(
|
|
73
|
+
Some("is:issue Event".into()),
|
|
74
|
+
Some("owner/repo".into()),
|
|
75
|
+
Some("closed".into()),
|
|
76
|
+
None,
|
|
77
|
+
100,
|
|
78
|
+
None,
|
|
79
|
+
);
|
|
80
|
+
assert!(q.raw.contains("repo:owner/repo"));
|
|
81
|
+
assert!(q.raw.contains("state:closed"));
|
|
82
|
+
assert!(q.raw.contains("is:issue Event"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#[test]
|
|
86
|
+
fn build_query_does_not_duplicate_repo() {
|
|
87
|
+
let q = Query::build(
|
|
88
|
+
Some("is:issue repo:owner/repo".into()),
|
|
89
|
+
Some("other/repo".into()),
|
|
90
|
+
None,
|
|
91
|
+
None,
|
|
92
|
+
100,
|
|
93
|
+
None,
|
|
94
|
+
);
|
|
95
|
+
assert_eq!(q.raw.matches("repo:").count(), 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[test]
|
|
99
|
+
fn build_query_handles_multiple_labels() {
|
|
100
|
+
let q = Query::build(None, None, None, Some("bug,enhancement".into()), 100, None);
|
|
101
|
+
assert!(q.raw.contains("label:bug"));
|
|
102
|
+
assert!(q.raw.contains("label:enhancement"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[test]
|
|
106
|
+
fn build_query_empty_produces_empty_raw() {
|
|
107
|
+
let q = Query::build(None, None, None, None, 100, None);
|
|
108
|
+
assert_eq!(q.raw.trim(), "");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/// Integration tests — require a live GitHub API token.
|
|
2
|
+
/// Run with: GITHUB_TOKEN=ghp_... cargo test -- --include-ignored
|
|
3
|
+
///
|
|
4
|
+
/// These tests use schemaorg/schemaorg#1842 as a known stable fixture.
|
|
5
|
+
|
|
6
|
+
#[cfg(test)]
|
|
7
|
+
mod tests {
|
|
8
|
+
use problems99::source::{Query, Source, github_issues::GitHubIssues};
|
|
9
|
+
|
|
10
|
+
fn token() -> Option<String> {
|
|
11
|
+
std::env::var("GITHUB_TOKEN").ok()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
#[ignore = "requires GITHUB_TOKEN and live network"]
|
|
16
|
+
fn fetch_known_issue_1842() {
|
|
17
|
+
let source = GitHubIssues::new().unwrap();
|
|
18
|
+
let conv = source.fetch_one("schemaorg/schemaorg", 1842).unwrap();
|
|
19
|
+
assert_eq!(conv.id, 1842);
|
|
20
|
+
assert_eq!(conv.title, "Online-only events");
|
|
21
|
+
assert_eq!(conv.state, "closed");
|
|
22
|
+
assert!(conv.body.is_some());
|
|
23
|
+
assert!(!conv.comments.is_empty());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
#[ignore = "requires GITHUB_TOKEN and live network"]
|
|
28
|
+
fn search_returns_results() {
|
|
29
|
+
let source = GitHubIssues::new().unwrap();
|
|
30
|
+
let query = Query::build(
|
|
31
|
+
Some("is:issue state:closed EventSeries repo:schemaorg/schemaorg".into()),
|
|
32
|
+
None,
|
|
33
|
+
None,
|
|
34
|
+
None,
|
|
35
|
+
10,
|
|
36
|
+
token(),
|
|
37
|
+
);
|
|
38
|
+
let results = source.fetch(&query).unwrap();
|
|
39
|
+
assert!(!results.is_empty());
|
|
40
|
+
for conv in &results {
|
|
41
|
+
assert!(!conv.title.is_empty());
|
|
42
|
+
assert_eq!(conv.state, "closed");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[test]
|
|
47
|
+
#[ignore = "requires GITHUB_TOKEN and live network"]
|
|
48
|
+
fn fetch_one_comment_has_author_and_body() {
|
|
49
|
+
let source = GitHubIssues::new().unwrap();
|
|
50
|
+
let conv = source.fetch_one("schemaorg/schemaorg", 1842).unwrap();
|
|
51
|
+
let first = conv
|
|
52
|
+
.comments
|
|
53
|
+
.first()
|
|
54
|
+
.expect("expected at least one comment");
|
|
55
|
+
assert!(first.author.is_some());
|
|
56
|
+
assert!(!first.created_at.is_empty());
|
|
57
|
+
}
|
|
58
|
+
}
|