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