@mbe24/99problems 0.1.1 → 0.2.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/Cargo.lock CHANGED
@@ -653,7 +653,7 @@ dependencies = [
653
653
 
654
654
  [[package]]
655
655
  name = "problems99"
656
- version = "0.1.1"
656
+ version = "0.2.0"
657
657
  dependencies = [
658
658
  "anyhow",
659
659
  "clap",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "problems99"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  edition = "2024"
5
5
  description = "GitHub issue conversation fetcher CLI"
6
6
  license = "Apache-2.0"
package/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  > AI-friendly access to GitHub issues.
10
10
 
11
- Fetch GitHub issue conversations as structured JSON or YAML ready for LLM pipelines, RAG, and bulk analysis. Uses the same search syntax as the GitHub web UI.
12
-
11
+ `99problems` is a command-line tool that fetches GitHub issue conversations including all commentsand exports them as structured **JSON** or **YAML**. It uses the same search syntax as the GitHub web UI, making it easy to export, analyse, or feed GitHub issues into LLM pipelines, RAG systems, vector stores, and data analysis workflows.
13
12
 
13
+ Built in Rust. Distributed as a single binary via npm and crates.io. No runtime dependencies.
14
14
 
15
15
  ## Installation
16
16
 
@@ -24,27 +24,27 @@ Or via cargo:
24
24
  cargo install problems99
25
25
  ```
26
26
 
27
- Pre-built binaries are available for Windows x64, Linux x64, Linux ARM64, macOS Intel, and macOS Apple Silicon. No runtime dependencies.
27
+ Pre-built binaries are available for Windows x64, Linux x64, Linux ARM64, macOS Intel, and macOS Apple Silicon.
28
28
 
29
29
  ## Usage
30
30
 
31
31
  ```bash
32
- # Fetch all closed issues mentioning "Event" from a repo
32
+ # Export all closed issues mentioning "Event" to JSON
33
33
  99problems -q "is:issue state:closed Event repo:schemaorg/schemaorg" -o output.json
34
34
 
35
- # Fetch a single issue
35
+ # Fetch a single issue with all its comments
36
36
  99problems --repo schemaorg/schemaorg --issue 1842
37
37
 
38
- # YAML output
38
+ # Export open bug issues as YAML
39
39
  99problems -q "state:open label:bug repo:owner/repo" --format yaml
40
40
 
41
- # Pipe into jq
41
+ # Pipe into jq for further processing
42
42
  99problems -q "state:closed repo:owner/repo" | jq '.[].title'
43
43
  ```
44
44
 
45
45
  ## Output
46
46
 
47
- Each result is a conversation object with the issue body and all comments:
47
+ Each result is a conversation object containing the issue body and all comments:
48
48
 
49
49
  ```json
50
50
  [
@@ -78,6 +78,7 @@ Each result is a conversation object with the issue body and all comments:
78
78
  Example `~/.99problems`:
79
79
 
80
80
  ```toml
81
+ [github]
81
82
  token = "ghp_your_personal_access_token"
82
83
  ```
83
84
 
@@ -88,31 +89,46 @@ repo = "owner/my-repo"
88
89
  state = "closed"
89
90
  ```
90
91
 
91
- Token is resolved in this order: `--token` flag → `GITHUB_TOKEN` env var → `./.99problems` → `~/.99problems`. Without a token the GitHub API rate limit is 60 requests/hour; with one it's 5,000/hour.
92
+ For self-hosted GitLab:
93
+
94
+ ```toml
95
+ platform = "gitlab"
96
+
97
+ [gitlab]
98
+ token = "glpat_your_token"
99
+ url = "https://gitlab.mycompany.com"
100
+ ```
101
+
102
+ Token is resolved in this order: `--token` flag → `GITHUB_TOKEN`/`GITLAB_TOKEN`/`BITBUCKET_TOKEN` env var → `./.99problems` → `~/.99problems`. Without a token the GitHub API rate limit is 60 requests/hour; with one it's 5,000/hour.
92
103
 
93
104
  ## Options
94
105
 
95
106
  ```
96
107
  Options:
97
- -q, --query <QUERY> Full GitHub search query (web UI syntax)
98
- --repo <REPO> Shorthand for "repo:owner/name"
99
- --state <STATE> Shorthand for "state:open|closed"
100
- --labels <LABELS> Comma-separated labels, e.g. "bug,help wanted"
101
- --issue <ISSUE> Fetch a single issue by number (requires --repo)
102
- --source <SOURCE> Data source [default: github-issues]
103
- --format <FORMAT> Output format: json | yaml [default: json]
104
- -o, --output <FILE> Write to file instead of stdout
105
- --token <TOKEN> GitHub personal access token
106
- -h, --help Print help
107
- -V, --version Print version
108
+ -q, --query <QUERY> Full search query (platform web UI syntax)
109
+ --repo <REPO> Shorthand for "repo:owner/name"
110
+ --state <STATE> Shorthand for "state:open|closed"
111
+ --labels <LABELS> Comma-separated labels, e.g. "bug,help wanted"
112
+ --author <AUTHOR> Filter by issue/PR author
113
+ --since <DATE> Only items created on or after YYYY-MM-DD
114
+ --milestone <NAME> Filter by milestone title or number
115
+ --issue <ISSUE> Fetch a single issue by number (requires --repo)
116
+ --platform <PLATFORM> Platform: github | gitlab | bitbucket [default: github]
117
+ --type <TYPE> Content type: issue | pr [default: issue]
118
+ --format <FORMAT> Output format: json | yaml [default: json]
119
+ -o, --output <FILE> Write to file instead of stdout
120
+ --token <TOKEN> Personal access token
121
+ -h, --help Print help
122
+ -V, --version Print version
108
123
  ```
109
124
 
110
125
  ## Use cases
111
126
 
112
- - **LLM context / RAG** — load issue history into a vector store or prompt
113
- - **Issue triage** — process closed issues in bulk with Python or JavaScript
114
- - **Dataset generation** — build labelled datasets from GitHub discussions
115
- - **Changelog automation** — extract closed issues for release notes
127
+ - **LLM context / RAG** — export issue history into a vector store or use as prompt context
128
+ - **Issue triage and analysis** — bulk-process GitHub issues with Python, JavaScript, or any data tool
129
+ - **Training data generation** — build labelled datasets from GitHub discussions and bug reports
130
+ - **Changelog and release notes** — extract closed issues for automated release documentation
131
+ - **Knowledge base indexing** — crawl project issue trackers for search and retrieval systems
116
132
 
117
133
  ## Contributing
118
134
 
@@ -121,4 +137,3 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
121
137
  ## License
122
138
 
123
139
  See [LICENSE](LICENSE).
124
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbe24/99problems",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "GitHub issue conversation fetcher CLI",
5
5
  "bin": {
6
6
  "99problems": "./npm/bin/99problems.js"
@@ -9,11 +9,11 @@
9
9
  "postinstall": "node npm/install.js"
10
10
  },
11
11
  "optionalDependencies": {
12
- "@mbe24/99problems-win32-x64": "0.1.1",
13
- "@mbe24/99problems-linux-x64": "0.1.1",
14
- "@mbe24/99problems-darwin-x64": "0.1.1",
15
- "@mbe24/99problems-darwin-arm64": "0.1.1",
16
- "@mbe24/99problems-linux-arm64": "0.1.1"
12
+ "@mbe24/99problems-win32-x64": "0.2.0",
13
+ "@mbe24/99problems-linux-x64": "0.2.0",
14
+ "@mbe24/99problems-darwin-x64": "0.2.0",
15
+ "@mbe24/99problems-darwin-arm64": "0.2.0",
16
+ "@mbe24/99problems-linux-arm64": "0.2.0"
17
17
  },
18
18
  "license": "MIT",
19
19
  "repository": {
package/src/config.rs CHANGED
@@ -2,50 +2,120 @@ use anyhow::Result;
2
2
  use serde::Deserialize;
3
3
  use std::path::PathBuf;
4
4
 
5
- /// Represents values that can be set in a .99problems TOML dotfile.
5
+ /// Per-platform credentials and settings.
6
+ #[derive(Debug, Default, Deserialize, Clone)]
7
+ pub struct PlatformConfig {
8
+ pub token: Option<String>,
9
+ /// Base URL override for self-hosted instances (e.g. GitLab)
10
+ pub url: Option<String>,
11
+ }
12
+
13
+ /// Top-level dotfile structure (.99problems).
6
14
  #[derive(Debug, Default, Deserialize)]
7
15
  pub struct DotfileConfig {
8
- pub token: Option<String>,
16
+ /// Default platform (overridden by --platform flag)
17
+ pub platform: Option<String>,
18
+ /// Default type: "issue" or "pr" (overridden by --type flag)
19
+ #[serde(rename = "type")]
20
+ pub kind: Option<String>,
9
21
  pub repo: Option<String>,
10
22
  pub state: Option<String>,
11
23
  pub per_page: Option<u32>,
24
+ pub github: Option<PlatformConfig>,
25
+ pub gitlab: Option<PlatformConfig>,
26
+ pub bitbucket: Option<PlatformConfig>,
12
27
  }
13
28
 
14
- /// Fully resolved config after merging home + local dotfiles + env var.
29
+ /// Fully resolved config after merging home + local dotfiles + env vars.
15
30
  #[derive(Debug, Default)]
16
31
  pub struct Config {
32
+ pub platform: String,
33
+ pub kind: String,
17
34
  pub token: Option<String>,
18
35
  pub repo: Option<String>,
19
36
  pub state: Option<String>,
20
37
  pub per_page: u32,
38
+ /// Base URL for the active platform (for self-hosted instances)
39
+ #[allow(dead_code)]
40
+ pub platform_url: Option<String>,
21
41
  }
22
42
 
23
43
  impl Config {
24
- /// Load and merge: home dotfile (base) → local dotfile (override) → env var.
25
- /// CLI flags are merged later in main.rs on top of this.
44
+ /// Load and merge: home dotfile (base) → local dotfile (override) → env vars.
45
+ /// CLI flags are applied on top in main.rs.
26
46
  pub fn load() -> Result<Self> {
27
47
  let home = load_dotfile(home_dotfile_path())?;
28
48
  let local = load_dotfile(local_dotfile_path())?;
29
49
 
30
- // Merge: local wins over home
31
- let token = std::env::var("GITHUB_TOKEN")
32
- .ok()
33
- .or_else(|| local.token.clone())
34
- .or_else(|| home.token.clone());
50
+ let platform = local
51
+ .platform
52
+ .clone()
53
+ .or_else(|| home.platform.clone())
54
+ .unwrap_or_else(|| "github".into());
35
55
 
36
- let repo = local.repo.or(home.repo);
37
- let state = local.state.or(home.state);
56
+ let kind = local
57
+ .kind
58
+ .clone()
59
+ .or_else(|| home.kind.clone())
60
+ .unwrap_or_else(|| "issue".into());
61
+
62
+ let repo = local.repo.clone().or(home.repo.clone());
63
+ let state = local.state.clone().or(home.state.clone());
38
64
  let per_page = local.per_page.or(home.per_page).unwrap_or(100);
39
65
 
66
+ // Resolve token: env var → local dotfile → home dotfile
67
+ let env_var = match platform.as_str() {
68
+ "github" => "GITHUB_TOKEN",
69
+ "gitlab" => "GITLAB_TOKEN",
70
+ "bitbucket" => "BITBUCKET_TOKEN",
71
+ _ => "GITHUB_TOKEN",
72
+ };
73
+ let (dotfile_token, platform_url) = resolve_platform_token(&platform, &local, &home);
74
+ let token = std::env::var(env_var).ok().or(dotfile_token);
75
+
40
76
  Ok(Self {
77
+ platform,
78
+ kind,
41
79
  token,
42
80
  repo,
43
81
  state,
44
82
  per_page,
83
+ platform_url,
45
84
  })
46
85
  }
47
86
  }
48
87
 
88
+ /// Resolve token and optional URL for the given platform from dotfiles only.
89
+ fn resolve_platform_token(
90
+ platform: &str,
91
+ local: &DotfileConfig,
92
+ home: &DotfileConfig,
93
+ ) -> (Option<String>, Option<String>) {
94
+ let local_platform = platform_section(platform, local);
95
+ let home_platform = platform_section(platform, home);
96
+
97
+ let token = local_platform
98
+ .as_ref()
99
+ .and_then(|p| p.token.clone())
100
+ .or_else(|| home_platform.as_ref().and_then(|p| p.token.clone()));
101
+
102
+ let url = local_platform
103
+ .as_ref()
104
+ .and_then(|p| p.url.clone())
105
+ .or_else(|| home_platform.as_ref().and_then(|p| p.url.clone()));
106
+
107
+ (token, url)
108
+ }
109
+
110
+ fn platform_section<'a>(platform: &str, cfg: &'a DotfileConfig) -> Option<&'a PlatformConfig> {
111
+ match platform {
112
+ "github" => cfg.github.as_ref(),
113
+ "gitlab" => cfg.gitlab.as_ref(),
114
+ "bitbucket" => cfg.bitbucket.as_ref(),
115
+ _ => None,
116
+ }
117
+ }
118
+
49
119
  fn home_dotfile_path() -> Option<PathBuf> {
50
120
  dirs::home_dir().map(|h| h.join(".99problems"))
51
121
  }
@@ -71,7 +141,7 @@ mod tests {
71
141
  #[test]
72
142
  fn dotfile_config_defaults_when_missing() {
73
143
  let cfg = load_dotfile(None).unwrap();
74
- assert!(cfg.token.is_none());
144
+ assert!(cfg.github.is_none());
75
145
  assert!(cfg.repo.is_none());
76
146
  assert!(cfg.per_page.is_none());
77
147
  }
@@ -79,23 +149,52 @@ mod tests {
79
149
  #[test]
80
150
  fn dotfile_config_parses_toml() {
81
151
  let toml = r#"
82
- token = "ghp_test"
83
152
  repo = "owner/repo"
84
153
  state = "closed"
85
154
  per_page = 50
155
+
156
+ [github]
157
+ token = "ghp_test"
86
158
  "#;
87
159
  let cfg: DotfileConfig = toml::from_str(toml).unwrap();
88
- assert_eq!(cfg.token.as_deref(), Some("ghp_test"));
89
160
  assert_eq!(cfg.repo.as_deref(), Some("owner/repo"));
90
161
  assert_eq!(cfg.per_page, Some(50));
162
+ assert_eq!(
163
+ cfg.github.as_ref().and_then(|g| g.token.as_deref()),
164
+ Some("ghp_test")
165
+ );
91
166
  }
92
167
 
93
168
  #[test]
94
169
  fn config_per_page_defaults_to_100() {
95
- // Simulate both dotfiles missing, no env var
96
170
  let home = DotfileConfig::default();
97
171
  let local = DotfileConfig::default();
98
172
  let per_page = local.per_page.or(home.per_page).unwrap_or(100);
99
173
  assert_eq!(per_page, 100);
100
174
  }
175
+
176
+ #[test]
177
+ fn config_platform_defaults_to_github() {
178
+ let home = DotfileConfig::default();
179
+ let local = DotfileConfig::default();
180
+ let platform = local
181
+ .platform
182
+ .or(home.platform)
183
+ .unwrap_or_else(|| "github".into());
184
+ assert_eq!(platform, "github");
185
+ }
186
+
187
+ #[test]
188
+ fn resolve_token_uses_platform_section() {
189
+ let home = DotfileConfig::default();
190
+ let local = DotfileConfig {
191
+ github: Some(PlatformConfig {
192
+ token: Some("ghp_section".into()),
193
+ url: None,
194
+ }),
195
+ ..Default::default()
196
+ };
197
+ let (token, _) = resolve_platform_token("github", &local, &home);
198
+ assert_eq!(token.as_deref(), Some("ghp_section"));
199
+ }
101
200
  }
package/src/main.rs CHANGED
@@ -17,10 +17,36 @@ enum OutputFormat {
17
17
  Yaml,
18
18
  }
19
19
 
20
+ #[derive(Debug, Clone, ValueEnum, PartialEq)]
21
+ enum Platform {
22
+ Github,
23
+ Gitlab,
24
+ Bitbucket,
25
+ }
26
+
27
+ impl Platform {
28
+ fn as_str(&self) -> &str {
29
+ match self {
30
+ Platform::Github => "github",
31
+ Platform::Gitlab => "gitlab",
32
+ Platform::Bitbucket => "bitbucket",
33
+ }
34
+ }
35
+ }
36
+
20
37
  #[derive(Debug, Clone, ValueEnum)]
21
- enum SourceKind {
22
- GithubIssues,
23
- GithubPrs,
38
+ enum ContentType {
39
+ Issue,
40
+ Pr,
41
+ }
42
+
43
+ impl ContentType {
44
+ fn as_str(&self) -> &str {
45
+ match self {
46
+ ContentType::Issue => "issue",
47
+ ContentType::Pr => "pr",
48
+ }
49
+ }
24
50
  }
25
51
 
26
52
  #[derive(Parser, Debug)]
@@ -30,8 +56,8 @@ enum SourceKind {
30
56
  version
31
57
  )]
32
58
  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"
59
+ /// Full search query (same syntax as the platform's web UI search bar)
60
+ /// e.g. "state:closed Event repo:owner/repo"
35
61
  #[arg(short = 'q', long)]
36
62
  query: Option<String>,
37
63
 
@@ -47,13 +73,29 @@ struct Cli {
47
73
  #[arg(long)]
48
74
  labels: Option<String>,
49
75
 
76
+ /// Filter by issue/PR author
77
+ #[arg(long)]
78
+ author: Option<String>,
79
+
80
+ /// Only include items created on or after this date (YYYY-MM-DD), e.g. "2024-01-01"
81
+ #[arg(long)]
82
+ since: Option<String>,
83
+
84
+ /// Filter by milestone title or number
85
+ #[arg(long)]
86
+ milestone: Option<String>,
87
+
50
88
  /// Fetch a single issue by number (bypasses search)
51
89
  #[arg(long)]
52
90
  issue: Option<u64>,
53
91
 
54
- /// Data source to use
55
- #[arg(long, value_enum, default_value = "github-issues")]
56
- source: SourceKind,
92
+ /// Platform to fetch from [default: github]
93
+ #[arg(long, value_enum)]
94
+ platform: Option<Platform>,
95
+
96
+ /// Content type to fetch [default: issue]
97
+ #[arg(long = "type", value_enum)]
98
+ kind: Option<ContentType>,
57
99
 
58
100
  /// Output format
59
101
  #[arg(long, value_enum, default_value = "json")]
@@ -63,7 +105,7 @@ struct Cli {
63
105
  #[arg(short = 'o', long)]
64
106
  output: Option<String>,
65
107
 
66
- /// GitHub personal access token (overrides GITHUB_TOKEN and dotfile)
108
+ /// Personal access token (overrides env var and dotfile)
67
109
  #[arg(long)]
68
110
  token: Option<String>,
69
111
  }
@@ -72,38 +114,48 @@ fn main() -> Result<()> {
72
114
  let cli = Cli::parse();
73
115
  let mut cfg = Config::load()?;
74
116
 
75
- // CLI token overrides everything
117
+ // CLI flags override config values
118
+ if let Some(p) = &cli.platform {
119
+ cfg.platform = p.as_str().to_owned();
120
+ }
121
+ if let Some(k) = &cli.kind {
122
+ cfg.kind = k.as_str().to_owned();
123
+ }
76
124
  if let Some(t) = cli.token {
77
125
  cfg.token = Some(t);
78
126
  }
79
127
 
80
- // Override repo/state from CLI if provided
81
128
  let repo = cli.repo.or(cfg.repo.clone());
82
129
  let state = cli.state.or(cfg.state.clone());
83
130
 
84
- // Build the formatter
85
131
  let formatter: Box<dyn Formatter> = match cli.format {
86
132
  OutputFormat::Json => Box::new(JsonFormatter),
87
133
  OutputFormat::Yaml => Box::new(YamlFormatter),
88
134
  };
89
135
 
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
- }
136
+ let source: Box<dyn Source> = match cfg.platform.as_str() {
137
+ "github" => Box::new(GitHubIssues::new()?),
138
+ other => return Err(anyhow!("Platform '{other}' is not yet supported")),
96
139
  };
97
140
 
98
141
  let conversations = if let Some(issue_id) = cli.issue {
99
- // Single-issue mode
100
142
  let r = repo
101
143
  .as_deref()
102
144
  .ok_or_else(|| anyhow!("--repo is required when using --issue"))?;
103
145
  vec![source.fetch_one(r, issue_id)?]
104
146
  } else {
105
- // Search mode
106
- let query = Query::build(cli.query, repo, state, cli.labels, cfg.per_page, cfg.token);
147
+ let query = Query::build(
148
+ cli.query,
149
+ &cfg.kind,
150
+ repo,
151
+ state,
152
+ cli.labels,
153
+ cli.author,
154
+ cli.since,
155
+ cli.milestone,
156
+ cfg.per_page,
157
+ cfg.token,
158
+ );
107
159
  if query.raw.trim().is_empty() {
108
160
  return Err(anyhow!(
109
161
  "No query specified. Use -q or provide --repo/--state/--labels."
@@ -1,10 +1,14 @@
1
1
  use anyhow::{Result, anyhow};
2
- use reqwest::blocking::Client;
2
+ use reqwest::blocking::{Client, RequestBuilder};
3
3
  use serde::Deserialize;
4
4
 
5
5
  use super::{Query, Source};
6
6
  use crate::model::{Comment, Conversation};
7
7
 
8
+ const GITHUB_API_BASE: &str = "https://api.github.com";
9
+ const GITHUB_API_VERSION: &str = "2022-11-28";
10
+ const PAGE_SIZE: u32 = 100;
11
+
8
12
  pub struct GitHubIssues {
9
13
  client: Client,
10
14
  }
@@ -12,13 +16,19 @@ pub struct GitHubIssues {
12
16
  impl GitHubIssues {
13
17
  pub fn new() -> Result<Self> {
14
18
  let client = Client::builder()
15
- .user_agent("99problems-cli/0.1.0")
19
+ .user_agent(concat!("99problems-cli/", env!("CARGO_PKG_VERSION")))
16
20
  .build()?;
17
21
  Ok(Self { client })
18
22
  }
19
23
 
20
- fn auth_header(token: &Option<String>) -> Option<String> {
21
- token.as_ref().map(|t| format!("Bearer {t}"))
24
+ /// Adds Authorization + API version headers when a token is present.
25
+ fn apply_auth(req: RequestBuilder, token: &Option<String>) -> RequestBuilder {
26
+ match token.as_ref() {
27
+ Some(t) => req
28
+ .header("Authorization", format!("Bearer {t}"))
29
+ .header("X-GitHub-Api-Version", GITHUB_API_VERSION),
30
+ None => req,
31
+ }
22
32
  }
23
33
 
24
34
  fn get_pages<T: for<'de> Deserialize<'de>>(
@@ -31,17 +41,11 @@ impl GitHubIssues {
31
41
  let mut page = 1u32;
32
42
 
33
43
  loop {
34
- let mut req = self.client.get(url).query(&[
44
+ let req = self.client.get(url).query(&[
35
45
  ("per_page", per_page.to_string()),
36
46
  ("page", page.to_string()),
37
47
  ]);
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
-
48
+ let req = Self::apply_auth(req, token);
45
49
  let resp = req.send()?;
46
50
 
47
51
  if !resp.status().is_success() {
@@ -101,24 +105,19 @@ struct UserItem {
101
105
 
102
106
  impl Source for GitHubIssues {
103
107
  fn fetch(&self, query: &Query) -> Result<Vec<Conversation>> {
104
- let search_url = "https://api.github.com/search/issues";
108
+ let search_url = format!("{GITHUB_API_BASE}/search/issues");
105
109
  let mut page = 1u32;
106
110
  let mut all_issues: Vec<IssueItem> = vec![];
107
111
 
108
112
  loop {
109
- let mut req = self.client.get(search_url).query(&[
113
+ let req = self.client.get(&search_url).query(&[
110
114
  ("q", query.raw.as_str()),
111
115
  ("per_page", "100"),
112
116
  ("page", &page.to_string()),
113
117
  ]);
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
-
118
+ let req = Self::apply_auth(req, &query.token);
121
119
  let resp = req.send()?;
120
+
122
121
  if !resp.status().is_success() {
123
122
  return Err(anyhow!(
124
123
  "GitHub search error {}: {}",
@@ -128,7 +127,7 @@ impl Source for GitHubIssues {
128
127
  }
129
128
 
130
129
  let search: SearchResponse = resp.json()?;
131
- let done = search.items.len() < 100;
130
+ let done = search.items.len() < PAGE_SIZE as usize;
132
131
  all_issues.extend(search.items);
133
132
  if done {
134
133
  break;
@@ -144,11 +143,11 @@ impl Source for GitHubIssues {
144
143
  let mut conversations = vec![];
145
144
  for issue in all_issues {
146
145
  let comments_url = format!(
147
- "https://api.github.com/repos/{repo}/issues/{}/comments",
146
+ "{GITHUB_API_BASE}/repos/{repo}/issues/{}/comments",
148
147
  issue.number
149
148
  );
150
149
  let raw_comments: Vec<CommentItem> =
151
- self.get_pages(&comments_url, &query.token, 100)?;
150
+ self.get_pages(&comments_url, &query.token, PAGE_SIZE)?;
152
151
 
153
152
  conversations.push(Conversation {
154
153
  id: issue.number,
@@ -170,11 +169,8 @@ impl Source for GitHubIssues {
170
169
  }
171
170
 
172
171
  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()?;
172
+ let issue_url = format!("{GITHUB_API_BASE}/repos/{repo}/issues/{issue_id}");
173
+ let resp = self.client.get(&issue_url).send()?;
178
174
  if !resp.status().is_success() {
179
175
  return Err(anyhow!(
180
176
  "GitHub issue error {}: {}",
@@ -184,9 +180,8 @@ impl Source for GitHubIssues {
184
180
  }
185
181
  let issue: IssueItem = resp.json()?;
186
182
 
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)?;
183
+ let comments_url = format!("{GITHUB_API_BASE}/repos/{repo}/issues/{issue_id}/comments");
184
+ let raw_comments: Vec<CommentItem> = self.get_pages(&comments_url, &None, PAGE_SIZE)?;
190
185
 
191
186
  Ok(Conversation {
192
187
  id: issue.number,
package/src/source/mod.rs CHANGED
@@ -13,7 +13,7 @@ pub trait Source {
13
13
  #[derive(Debug, Default, Clone)]
14
14
  #[allow(dead_code)]
15
15
  pub struct Query {
16
- /// Full raw query string (GitHub search syntax), e.g. "is:issue state:closed Event repo:owner/repo"
16
+ /// Full raw query string (platform search syntax), e.g. "state:closed Event repo:owner/repo"
17
17
  pub raw: String,
18
18
  pub per_page: u32,
19
19
  pub token: Option<String>,
@@ -21,12 +21,17 @@ pub struct Query {
21
21
 
22
22
  impl Query {
23
23
  /// Build a query by merging a raw string with convenience shorthands.
24
- /// Shorthands are only appended if their qualifier isn't already present.
24
+ /// Shorthands are only appended if their qualifier isn't already present in the raw string.
25
+ #[allow(clippy::too_many_arguments)]
25
26
  pub fn build(
26
27
  raw: Option<String>,
28
+ kind: &str,
27
29
  repo: Option<String>,
28
30
  state: Option<String>,
29
31
  labels: Option<String>,
32
+ author: Option<String>,
33
+ since: Option<String>,
34
+ milestone: Option<String>,
30
35
  per_page: u32,
31
36
  token: Option<String>,
32
37
  ) -> Self {
@@ -36,6 +41,17 @@ impl Query {
36
41
  parts.push(r);
37
42
  }
38
43
 
44
+ // Inject type qualifier unless already present
45
+ if !parts
46
+ .iter()
47
+ .any(|p| p.contains("is:issue") || p.contains("is:pr") || p.contains("type:"))
48
+ {
49
+ match kind {
50
+ "pr" => parts.push("is:pr".into()),
51
+ _ => parts.push("is:issue".into()),
52
+ }
53
+ }
54
+
39
55
  if let Some(repo) = repo
40
56
  && !parts.iter().any(|p| p.contains("repo:"))
41
57
  {
@@ -54,6 +70,21 @@ impl Query {
54
70
  }
55
71
  }
56
72
  }
73
+ if let Some(author) = author
74
+ && !parts.iter().any(|p| p.contains("author:"))
75
+ {
76
+ parts.push(format!("author:{author}"));
77
+ }
78
+ if let Some(since) = since
79
+ && !parts.iter().any(|p| p.contains("created:"))
80
+ {
81
+ parts.push(format!("created:>={since}"));
82
+ }
83
+ if let Some(milestone) = milestone
84
+ && !parts.iter().any(|p| p.contains("milestone:"))
85
+ {
86
+ parts.push(format!("milestone:{milestone}"));
87
+ }
57
88
 
58
89
  Query {
59
90
  raw: parts.join(" "),
@@ -67,29 +98,36 @@ impl Query {
67
98
  mod tests {
68
99
  use super::*;
69
100
 
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()),
101
+ fn build(raw: Option<&str>, kind: &str, repo: Option<&str>, state: Option<&str>) -> Query {
102
+ Query::build(
103
+ raw.map(|s| s.into()),
104
+ kind,
105
+ repo.map(|s| s.into()),
106
+ state.map(|s| s.into()),
107
+ None,
108
+ None,
109
+ None,
76
110
  None,
77
111
  100,
78
112
  None,
79
- );
113
+ )
114
+ }
115
+
116
+ #[test]
117
+ fn build_query_appends_repo_and_state() {
118
+ let q = build(Some("Event"), "issue", Some("owner/repo"), Some("closed"));
80
119
  assert!(q.raw.contains("repo:owner/repo"));
81
120
  assert!(q.raw.contains("state:closed"));
82
- assert!(q.raw.contains("is:issue Event"));
121
+ assert!(q.raw.contains("Event"));
122
+ assert!(q.raw.contains("is:issue"));
83
123
  }
84
124
 
85
125
  #[test]
86
126
  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,
127
+ let q = build(
128
+ Some("is:issue repo:owner/repo"),
129
+ "issue",
130
+ Some("other/repo"),
93
131
  None,
94
132
  );
95
133
  assert_eq!(q.raw.matches("repo:").count(), 1);
@@ -97,14 +135,73 @@ mod tests {
97
135
 
98
136
  #[test]
99
137
  fn build_query_handles_multiple_labels() {
100
- let q = Query::build(None, None, None, Some("bug,enhancement".into()), 100, None);
138
+ let q = Query::build(
139
+ None,
140
+ "issue",
141
+ None,
142
+ None,
143
+ Some("bug,enhancement".into()),
144
+ None,
145
+ None,
146
+ None,
147
+ 100,
148
+ None,
149
+ );
101
150
  assert!(q.raw.contains("label:bug"));
102
151
  assert!(q.raw.contains("label:enhancement"));
103
152
  }
104
153
 
105
154
  #[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(), "");
155
+ fn build_query_empty_produces_type_qualifier() {
156
+ let q = build(None, "issue", None, None);
157
+ assert!(q.raw.contains("is:issue"));
158
+ }
159
+
160
+ #[test]
161
+ fn build_query_pr_type_injects_is_pr() {
162
+ let q = build(None, "pr", Some("owner/repo"), None);
163
+ assert!(q.raw.contains("is:pr"));
164
+ assert!(!q.raw.contains("is:issue"));
165
+ }
166
+
167
+ #[test]
168
+ fn build_query_does_not_duplicate_type() {
169
+ let q = build(Some("is:pr repo:owner/repo"), "pr", None, None);
170
+ assert_eq!(q.raw.matches("is:pr").count(), 1);
171
+ }
172
+
173
+ #[test]
174
+ fn build_query_author_and_since() {
175
+ let q = Query::build(
176
+ None,
177
+ "issue",
178
+ None,
179
+ None,
180
+ None,
181
+ Some("octocat".into()),
182
+ Some("2024-01-01".into()),
183
+ None,
184
+ 100,
185
+ None,
186
+ );
187
+ assert!(q.raw.contains("author:octocat"));
188
+ assert!(q.raw.contains("created:>=2024-01-01"));
189
+ }
190
+
191
+ #[test]
192
+ fn build_query_milestone() {
193
+ let q = Query::build(
194
+ None,
195
+ "issue",
196
+ None,
197
+ None,
198
+ None,
199
+ None,
200
+ None,
201
+ Some("v1.0".into()),
202
+ 100,
203
+ None,
204
+ );
205
+ assert!(q.raw.contains("milestone:v1.0"));
109
206
  }
110
207
  }
@@ -8,7 +8,12 @@ mod tests {
8
8
  use problems99::source::{Query, Source, github_issues::GitHubIssues};
9
9
 
10
10
  fn token() -> Option<String> {
11
- std::env::var("GITHUB_TOKEN").ok()
11
+ // Prefer env var, fall back to dotfile config (same resolution as the binary)
12
+ std::env::var("GITHUB_TOKEN").ok().or_else(|| {
13
+ problems99::config::Config::load()
14
+ .ok()
15
+ .and_then(|c| c.token)
16
+ })
12
17
  }
13
18
 
14
19
  #[test]
@@ -29,6 +34,10 @@ mod tests {
29
34
  let source = GitHubIssues::new().unwrap();
30
35
  let query = Query::build(
31
36
  Some("is:issue state:closed EventSeries repo:schemaorg/schemaorg".into()),
37
+ "issue",
38
+ None,
39
+ None,
40
+ None,
32
41
  None,
33
42
  None,
34
43
  None,