@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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +40 -25
- 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/package.json +6 -6
- package/src/config.rs +115 -16
- package/src/main.rs +73 -21
- package/src/source/github_issues.rs +27 -32
- package/src/source/mod.rs +117 -20
- package/tests/integration.rs +10 -1
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
> AI-friendly access to GitHub issues.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
`99problems` is a command-line tool that fetches GitHub issue conversations — including all comments — and 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.
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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>
|
|
98
|
-
--repo <REPO>
|
|
99
|
-
--state <STATE>
|
|
100
|
-
--labels <LABELS>
|
|
101
|
-
--
|
|
102
|
-
--
|
|
103
|
-
--
|
|
104
|
-
|
|
105
|
-
--
|
|
106
|
-
|
|
107
|
-
|
|
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** —
|
|
113
|
-
- **Issue triage** — process
|
|
114
|
-
- **
|
|
115
|
-
- **Changelog
|
|
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
|
-
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mbe24/99problems",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
13
|
-
"@mbe24/99problems-linux-x64": "0.
|
|
14
|
-
"@mbe24/99problems-darwin-x64": "0.
|
|
15
|
-
"@mbe24/99problems-darwin-arm64": "0.
|
|
16
|
-
"@mbe24/99problems-linux-arm64": "0.
|
|
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
|
-
///
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
-
/// CLI flags are
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.or_else(||
|
|
34
|
-
.
|
|
50
|
+
let platform = local
|
|
51
|
+
.platform
|
|
52
|
+
.clone()
|
|
53
|
+
.or_else(|| home.platform.clone())
|
|
54
|
+
.unwrap_or_else(|| "github".into());
|
|
35
55
|
|
|
36
|
-
let
|
|
37
|
-
|
|
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.
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
34
|
-
/// e.g. "
|
|
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
|
-
///
|
|
55
|
-
#[arg(long, value_enum
|
|
56
|
-
|
|
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
|
-
///
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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/
|
|
19
|
+
.user_agent(concat!("99problems-cli/", env!("CARGO_PKG_VERSION")))
|
|
16
20
|
.build()?;
|
|
17
21
|
Ok(Self { client })
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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() <
|
|
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
|
-
"
|
|
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,
|
|
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!("
|
|
174
|
-
let
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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("
|
|
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 =
|
|
88
|
-
Some("is:issue repo:owner/repo"
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
|
107
|
-
let q =
|
|
108
|
-
|
|
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
|
}
|
package/tests/integration.rs
CHANGED
|
@@ -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
|
-
|
|
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,
|