Skip to content

Commit c5c7ee7

Browse files
authored
Add PR range mode and bump version to 0.1.4 (#13)
1 parent 00edd31 commit c5c7ee7

8 files changed

Lines changed: 173 additions & 7 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/target
22
dist/
3+
.DS_Store

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gh-context"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
edition = "2024"
55
repository = "https://github.com/hdcodedev/gh-context"
66
description = "A CLI tool for fetching GitHub Issue and Pull Request context."

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ Fetch context for a PR (creates `repo-issue-123/repo-issue-123.md` context by de
5858
gh-context owner/repo#123
5959
```
6060

61+
Fetch a PR range (inclusive, one file per PR in `repo-prs/` by default):
62+
```bash
63+
gh-context owner/repo --from 244 --to 276
64+
```
65+
If any PR in the range fails to fetch, the command continues and prints a failure summary.
66+
6167
Bulk fetch open issues for a repo (one file per issue, first page by default):
6268
```bash
6369
gh-context https://github.com/openai/codex/issues --bulk
@@ -94,3 +100,5 @@ cargo run -- https://github.com/hdcodedev/resume256/issues/48
94100
- `--state <open|closed|all>`: Issue state filter for bulk mode (default: open)
95101
- `--per-page <n>`: Items per page for bulk mode (default: 30)
96102
- `--pages <n>`: Number of pages to fetch in bulk mode (default: 1)
103+
- `--from <n>`: Start PR number for range mode (inclusive, requires `--to`)
104+
- `--to <n>`: End PR number for range mode (inclusive, requires `--from`)

‎src/__tests__/bulk_test.rs‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use crate::args::{Cli, IssueState, OutputFormat};
2-
use crate::bulk::{resolve_bulk_out_dir, validate_bulk_args};
2+
use crate::bulk::{
3+
resolve_bulk_out_dir, resolve_pr_range_out_dir, validate_bulk_args, validate_pr_range_args,
4+
};
35
use std::fs;
46
use std::path::PathBuf;
57

@@ -15,6 +17,8 @@ fn make_cli() -> Cli {
1517
state: IssueState::Open,
1618
per_page: 30,
1719
pages: 1,
20+
from: None,
21+
to: None,
1822
}
1923
}
2024

@@ -93,3 +97,53 @@ fn test_resolve_bulk_out_dir_rejects_file() {
9397

9498
let _ = fs::remove_file(&tmp_file);
9599
}
100+
101+
#[test]
102+
fn test_validate_pr_range_args_ok() {
103+
let mut cli = make_cli();
104+
cli.bulk = false;
105+
cli.from = Some(10);
106+
cli.to = Some(12);
107+
assert_eq!(validate_pr_range_args(&cli).unwrap(), (10, 12));
108+
}
109+
110+
#[test]
111+
fn test_validate_pr_range_args_requires_both_bounds() {
112+
let mut cli = make_cli();
113+
cli.bulk = false;
114+
cli.from = Some(10);
115+
let err = validate_pr_range_args(&cli).unwrap_err();
116+
assert!(err.to_string().contains("provided together"));
117+
}
118+
119+
#[test]
120+
fn test_validate_pr_range_args_rejects_descending_range() {
121+
let mut cli = make_cli();
122+
cli.bulk = false;
123+
cli.from = Some(12);
124+
cli.to = Some(10);
125+
let err = validate_pr_range_args(&cli).unwrap_err();
126+
assert!(err.to_string().contains("less than or equal"));
127+
}
128+
129+
#[test]
130+
fn test_validate_pr_range_args_rejects_issue_flag() {
131+
let mut cli = make_cli();
132+
cli.bulk = false;
133+
cli.issue = true;
134+
cli.from = Some(1);
135+
cli.to = Some(2);
136+
let err = validate_pr_range_args(&cli).unwrap_err();
137+
assert!(err.to_string().contains("PRs only"));
138+
}
139+
140+
#[test]
141+
fn test_resolve_pr_range_out_dir_default() {
142+
let mut cli = make_cli();
143+
cli.bulk = false;
144+
cli.out = None;
145+
let dir = resolve_pr_range_out_dir(&cli, "repo").unwrap();
146+
assert_eq!(dir, PathBuf::from("repo-prs"));
147+
assert!(dir.is_dir());
148+
let _ = fs::remove_dir_all(&dir);
149+
}

‎src/args.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,12 @@ pub struct Cli {
6666
/// Number of pages to fetch in bulk mode
6767
#[arg(long, default_value_t = 1)]
6868
pub pages: u32,
69+
70+
/// Start PR number for range mode (inclusive)
71+
#[arg(long)]
72+
pub from: Option<u64>,
73+
74+
/// End PR number for range mode (inclusive)
75+
#[arg(long)]
76+
pub to: Option<u64>,
6977
}

‎src/bulk.rs‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,47 @@ pub fn validate_bulk_args(cli: &Cli) -> Result<()> {
2020
}
2121

2222
pub fn resolve_bulk_out_dir(cli: &Cli, repo: &str) -> Result<PathBuf> {
23+
resolve_out_dir(cli, format!("{}-issues", repo), "bulk mode")
24+
}
25+
26+
pub fn validate_pr_range_args(cli: &Cli) -> Result<(u64, u64)> {
27+
if cli.bulk {
28+
return Err(anyhow!("--from/--to cannot be used with --bulk"));
29+
}
30+
if cli.issue {
31+
return Err(anyhow!("--from/--to supports PRs only; remove --issue"));
32+
}
33+
if cli.clip {
34+
return Err(anyhow!("--clip is not supported with --from/--to"));
35+
}
36+
37+
let (from, to) = match (cli.from, cli.to) {
38+
(Some(from), Some(to)) => (from, to),
39+
(Some(_), None) | (None, Some(_)) => {
40+
return Err(anyhow!("--from and --to must be provided together"));
41+
}
42+
(None, None) => return Err(anyhow!("--from and --to are required for PR range mode")),
43+
};
44+
45+
if from > to {
46+
return Err(anyhow!("--from must be less than or equal to --to"));
47+
}
48+
49+
Ok((from, to))
50+
}
51+
52+
pub fn resolve_pr_range_out_dir(cli: &Cli, repo: &str) -> Result<PathBuf> {
53+
resolve_out_dir(cli, format!("{}-prs", repo), "PR range mode")
54+
}
55+
56+
fn resolve_out_dir(cli: &Cli, default_name: String, mode_label: &str) -> Result<PathBuf> {
2357
let dir = if let Some(path) = &cli.out {
2458
if path.exists() && path.is_file() {
25-
return Err(anyhow!("--out must be a directory in bulk mode"));
59+
return Err(anyhow!("--out must be a directory in {}", mode_label));
2660
}
2761
path.clone()
2862
} else {
29-
PathBuf::from(format!("{}-issues", repo))
63+
PathBuf::from(default_name)
3064
};
3165

3266
if !dir.exists() {

‎src/main.rs‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ mod types;
77
#[cfg(test)]
88
mod __tests__;
99

10-
use anyhow::{Context, Result};
10+
use anyhow::{anyhow, Context, Result};
1111
use args::{Cli, OutputFormat};
12-
use bulk::{resolve_bulk_out_dir, validate_bulk_args};
12+
use bulk::{
13+
resolve_bulk_out_dir, resolve_pr_range_out_dir, validate_bulk_args, validate_pr_range_args,
14+
};
1315
use clap::Parser;
1416
use std::fs;
1517
use std::io::Write;
@@ -19,6 +21,65 @@ use types::Context as GhContext;
1921
fn main() -> Result<()> {
2022
let cli = Cli::parse();
2123

24+
if cli.from.is_some() || cli.to.is_some() {
25+
let (from, to) = validate_pr_range_args(&cli)?;
26+
let (owner, repo) = gh::parse_repo(&cli.input)?;
27+
let out_dir = resolve_pr_range_out_dir(&cli, &repo)?;
28+
let file_extension = output_extension(&cli.format);
29+
let mut failures: Vec<(u64, String)> = Vec::new();
30+
let mut generated_count = 0_u64;
31+
32+
for number in from..=to {
33+
let target = gh::Target {
34+
owner: owner.clone(),
35+
repo: repo.clone(),
36+
number,
37+
kind: gh::TargetType::Pr,
38+
};
39+
40+
let context = match gh::fetch_context(&target) {
41+
Ok(context) => context,
42+
Err(err) => {
43+
failures.push((number, err.to_string()));
44+
continue;
45+
}
46+
};
47+
let formatted_output = format_output(&context, &cli.format)?;
48+
let base = format!(
49+
"{}-{}-{}",
50+
repo, context.metadata.r#type, context.metadata.number
51+
);
52+
let file_path = out_dir.join(format!("{}.{}", base, file_extension));
53+
fs::write(&file_path, &formatted_output).with_context(|| {
54+
format!("Failed to write output to file: {:?}", file_path)
55+
})?;
56+
println!("Generated context in {}", file_path.display());
57+
generated_count += 1;
58+
}
59+
60+
if !failures.is_empty() {
61+
eprintln!(
62+
"Completed with {} failed PR(s) in range {}..={}",
63+
failures.len(),
64+
from,
65+
to
66+
);
67+
for (number, err) in &failures {
68+
eprintln!(" - PR #{}: {}", number, err);
69+
}
70+
}
71+
72+
if generated_count == 0 {
73+
return Err(anyhow!(
74+
"No PR context files were generated for range {}..={}",
75+
from,
76+
to
77+
));
78+
}
79+
80+
return Ok(());
81+
}
82+
2283
if cli.bulk {
2384
validate_bulk_args(&cli)?;
2485

0 commit comments

Comments
 (0)