Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ Use this when you run `99problems` directly in a terminal to fetch context from
99problems get --repo github/gitignore --id 2402 --type pr --include-review-comments

# Search GitLab issues
99problems get --platform gitlab -q "repo:veloren/veloren is:issue state:closed terrain"
99problems get --platform gitlab -q repo:veloren/veloren is:issue state:closed terrain

# Fetch Jira issue by key
99problems get --platform jira --id CLOUD-12817
99problems get jira --id CLOUD-12817

# Fetch Bitbucket Cloud PR by ID
99problems get --platform bitbucket --deployment cloud --repo workspace/repo_slug --id 1 --type pr
Expand All @@ -87,13 +87,13 @@ Use this when you run `99problems` directly in a terminal to fetch context from
99problems get --platform bitbucket --deployment selfhosted --url https://bitbucket.mycompany.com --repo PROJECT/repo_slug --id 1

# Stream as JSON Lines for pipelines
99problems get -q "repo:github/gitignore is:issue state:open" --output-mode stream --format jsonl
99problems get -q repo:github/gitignore is:issue state:open --output-mode stream --format jsonl
```

## Commands

```text
99problems get [OPTIONS] Fetch issue and pull request conversations
99problems get [INSTANCE] [OPTIONS] Fetch issue and pull request conversations
99problems skill init [OPTIONS] Scaffold the canonical Agent Skill
99problems config <SUBCOMMAND> Inspect and edit .99problems configuration
99problems completions <SHELL> Generate shell completion scripts
Expand Down Expand Up @@ -157,7 +157,7 @@ token = "pat_or_bearer_token"
Bitbucket support is pull-request only; when `--type` is omitted, `99problems` defaults to PRs.
For Bitbucket Cloud, use an app-password, repository access token, or workspace-level access token (premium feature) in `token`.

Selection order: `--instance` -> single configured instance -> `default_instance`.
Selection order: positional `INSTANCE`/`--instance` -> single configured instance -> `default_instance`.

### Telemetry

Expand Down
13 changes: 10 additions & 3 deletions docs/commands/get.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Fetch issue or pull-request conversations from configured providers.
Search mode:

```bash
99problems get -q "repo:owner/repo is:issue state:open"
99problems get -q repo:owner/repo is:issue state:open
```

ID mode:
Expand All @@ -16,14 +16,21 @@ ID mode:
99problems get --repo owner/repo --id 1842 --type issue
```

With positional instance alias:

```bash
99problems get jira -i CLOUD-12817
```

## Core Inputs

- `-q, --query`: raw provider query string.
- `-q, --query`: raw provider query string (accepts multiple tokens until the next flag).
- `-i, --id`: fetch one issue/PR directly.
- `-r, --repo`: provider repo/project shorthand.
- `-t, --type`: `issue` or `pr`.
- `-p, --platform`: direct platform selection.
- `-I, --instance`: select configured instance alias.
- `<instance>`: optional positional configured instance alias.
- `-I, --instance`: select configured instance alias (backward compatible with positional form).

## Query Shorthand Flags

Expand Down
82 changes: 75 additions & 7 deletions src/cmd/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,17 @@ impl ResolvedOutputFormat {
#[allow(clippy::struct_excessive_bools)]
#[command(
next_line_help = true,
after_help = "Examples:\n 99problems get --repo schemaorg/schemaorg --id 1842\n 99problems get --repo github/gitignore --id 2402 --type pr --include-review-comments\n 99problems get -q \"repo:owner/repo state:open label:bug\" --output-mode stream --format jsonl"
after_help = "Examples:\n 99problems get jira -i CLOUD-12817\n 99problems get --repo schemaorg/schemaorg --id 1842\n 99problems get --repo github/gitignore --id 2402 --type pr --include-review-comments\n 99problems get -q repo:owner/repo state:open label:bug --output-mode stream --format jsonl"
)]
pub(crate) struct GetArgs {
/// Named instance alias from .99problems ([instances.<alias>]) as first positional argument
#[arg(index = 1, value_name = "INSTANCE")]
pub(crate) instance_positional: Option<String>,

/// Full search query (same syntax as the platform's web UI search bar)
/// e.g. "state:closed Event repo:owner/repo"
#[arg(short = 'q', long)]
pub(crate) query: Option<String>,
#[arg(short = 'q', long, num_args = 1..)]
pub(crate) query: Option<Vec<String>>,

/// Shorthand for adding "repo:owner/repo" to the query (alias: --project)
#[arg(short = 'r', long, visible_alias = "project")]
Expand Down Expand Up @@ -425,8 +429,9 @@ fn default_platform_host(platform: &str) -> &'static str {
}

fn load_config_for_get(args: &GetArgs) -> Result<Config> {
let instance = resolve_instance_alias(args)?;
if args.platform.is_none()
&& args.instance.is_none()
&& instance.is_none()
&& args.url.is_none()
&& args.deployment.is_none()
&& args.kind.is_none()
Expand All @@ -441,7 +446,7 @@ fn load_config_for_get(args: &GetArgs) -> Result<Config> {

Config::load_with_options(ResolveOptions {
platform: args.platform.as_ref().map(Platform::as_str),
instance: args.instance.as_deref(),
instance,
url: args.url.as_deref(),
kind: args.kind.as_ref().map(ContentType::as_str),
deployment: args.deployment.as_ref().map(DeploymentType::as_str),
Expand Down Expand Up @@ -495,6 +500,29 @@ fn emit_get_warnings(cfg: &Config, args: &GetArgs) -> Result<()> {
Ok(())
}

/// Resolve instance alias from positional `<instance>` or `--instance`.
///
/// If both are provided they must match; otherwise a usage error is returned.
fn resolve_instance_alias(args: &GetArgs) -> Result<Option<&str>> {
match (
args.instance_positional.as_deref(),
args.instance.as_deref(),
) {
(Some(positional), Some(flag)) if positional != flag => Err(AppError::usage(format!(
"Conflicting instance values: positional instance '{positional}' does not match --instance '{flag}'. Use either `99problems get <instance> ...` or `99problems get --instance <instance> ...`."
))
.into()),
(Some(positional), _) => Ok(Some(positional)),
(None, Some(flag)) => Ok(Some(flag)),
(None, None) => Ok(None),
}
}

/// Join parsed `--query` tokens back into a single provider query string.
fn join_query_tokens(args: &GetArgs) -> Option<String> {
args.query.as_ref().map(|tokens| tokens.join(" "))
}

fn build_source_for_platform(cfg: &Config, telemetry_active: bool) -> Result<Box<dyn Source>> {
match cfg.platform.as_str() {
"github" => Ok(Box::new(GitHubSource::new(telemetry_active)?)),
Expand Down Expand Up @@ -558,7 +586,7 @@ fn build_fetch_request(cfg: &Config, args: &GetArgs) -> Result<FetchRequest> {
}

let query = Query::build(
args.query.clone(),
join_query_tokens(args),
effective_kind,
repo,
state,
Expand Down Expand Up @@ -792,6 +820,7 @@ mod tests {

fn args() -> GetArgs {
GetArgs {
instance_positional: None,
query: None,
repo: Some("owner/repo".into()),
state: None,
Expand Down Expand Up @@ -944,7 +973,7 @@ mod tests {
args.id = None;
args.no_body = true;
args.repo = Some("CPQ".into());
args.query = Some("architectural".into());
args.query = Some(vec!["architectural".into()]);
let req = build_fetch_request(&cfg, &args).unwrap();
assert!(!req.include_body);
}
Expand Down Expand Up @@ -995,4 +1024,43 @@ mod tests {
let cfg = bitbucket_config("selfhosted", "pr", true);
assert_eq!(trace_deployment_for_platform(&cfg), Some("dc"));
}

#[test]
fn resolve_instance_alias_uses_positional_value() {
let mut args = args();
args.instance_positional = Some("jira".into());
assert_eq!(resolve_instance_alias(&args).unwrap(), Some("jira"));
}

#[test]
fn resolve_instance_alias_allows_matching_positional_and_flag_values() {
let mut args = args();
args.instance_positional = Some("jira".into());
args.instance = Some("jira".into());
assert_eq!(resolve_instance_alias(&args).unwrap(), Some("jira"));
}

#[test]
fn resolve_instance_alias_rejects_conflicting_values() {
let mut args = args();
args.instance_positional = Some("jira".into());
args.instance = Some("gitlab".into());
let err = resolve_instance_alias(&args).unwrap_err().to_string();
assert!(err.contains("Conflicting instance values"));
assert!(err.contains("does not match"));
}

#[test]
fn join_query_tokens_combines_values_with_spaces() {
let mut args = args();
args.query = Some(vec![
"is:issue".into(),
"state:open".into(),
"architectural".into(),
]);
assert_eq!(
join_query_tokens(&args).as_deref(),
Some("is:issue state:open architectural")
);
}
}
56 changes: 55 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ enum ErrorFormat {
subcommand_required = true,
arg_required_else_help = true,
next_line_help = true,
after_help = "Examples:\n 99problems get --repo schemaorg/schemaorg --id 1842\n 99problems get -q \"repo:github/gitignore is:pr 2402\" --include-review-comments\n 99problems skill init\n 99problems man --output docs/man",
after_help = "Examples:\n 99problems get github --id 1842 --repo schemaorg/schemaorg\n 99problems get -q repo:github/gitignore is:pr 2402 --include-review-comments\n 99problems skill init\n 99problems man --output docs/man",
version
)]
struct Cli {
Expand Down Expand Up @@ -163,6 +163,7 @@ mod tests {
.expect("expected get subcommand to parse");
match cli.command {
Commands::Get(args) => {
assert_eq!(args.instance_positional.as_deref(), None);
assert_eq!(args.repo.as_deref(), Some("owner/repo"));
assert_eq!(args.id.as_deref(), Some("1"));
}
Expand All @@ -181,6 +182,7 @@ mod tests {
.expect("expected got alias to parse");
match cli.command {
Commands::Get(args) => {
assert_eq!(args.instance_positional.as_deref(), None);
assert_eq!(args.repo.as_deref(), Some("owner/repo"));
assert_eq!(args.id.as_deref(), Some("2"));
}
Expand Down Expand Up @@ -314,4 +316,56 @@ mod tests {
}
}
}

#[test]
fn parses_get_with_positional_instance_alias() {
let cli = Cli::try_parse_from(["99problems", "get", "jira", "-i", "25"])
.expect("expected positional instance alias to parse");
match cli.command {
Commands::Get(args) => {
assert_eq!(args.instance_positional.as_deref(), Some("jira"));
assert_eq!(args.instance.as_deref(), None);
assert_eq!(args.id.as_deref(), Some("25"));
}
Commands::Skill(_)
| Commands::Config(_)
| Commands::Completions { .. }
| Commands::Man(_) => {
panic!("expected get command")
}
}
}

#[test]
fn parses_get_query_as_unquoted_multi_token_value() {
let cli = Cli::try_parse_from([
"99problems",
"get",
"-q",
"is:issue",
"state:open",
"architectural",
"--no-comments",
])
.expect("expected multi-token query to parse");
match cli.command {
Commands::Get(args) => {
assert_eq!(
args.query,
Some(vec![
"is:issue".to_string(),
"state:open".to_string(),
"architectural".to_string()
])
);
assert!(args.no_comments);
}
Commands::Skill(_)
| Commands::Config(_)
| Commands::Completions { .. }
| Commands::Man(_) => {
panic!("expected get command")
}
}
}
}
Loading