diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index d03afb8d..795176bd 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -113,6 +113,10 @@ enum Verb { /// the valid names + descriptions for triage / labelling. `--json` /// emits the full label objects (id, name, color, description). RepoLabels(verbs::repo_labels::Args), + /// Search for repositories on the forge instance by keyword, topic, or + /// description. Not repo-scoped — queries the instance-wide explore + /// endpoint. `--json` emits the full repository objects. + RepoSearch(verbs::repo_search::Args), /// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments). Lint(verbs::lint::Args), /// List issues / PRs with filters (`--kind`, `--state`, `--assignee`, @@ -198,6 +202,7 @@ fn main() -> Result<()> { Verb::RepoCreate(a) => verbs::repo_create::run(&client, a), Verb::RepoAddCollaborator(a) => verbs::repo_add_collaborator::run(&client, a), Verb::RepoLabels(a) => verbs::repo_labels::run(&client, a), + Verb::RepoSearch(a) => verbs::repo_search::run(&client, a), Verb::Lint(a) => verbs::lint::run(&client, a), Verb::List(a) => verbs::list::run(&client, a), Verb::Milestone(a) => verbs::milestone::run(&client, a), diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 2a438aa4..784df9e1 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -37,6 +37,7 @@ pub mod reopen; pub mod repo_add_collaborator; pub mod repo_create; pub mod repo_labels; +pub mod repo_search; pub mod subscription; pub mod timeline; pub mod tree_sha; diff --git a/hive-forge/src/verbs/repo_search.rs b/hive-forge/src/verbs/repo_search.rs new file mode 100644 index 00000000..e5754d96 --- /dev/null +++ b/hive-forge/src/verbs/repo_search.rs @@ -0,0 +1,85 @@ +//! `repo-search [--query ] [--topic] [--include-desc] [--limit N]` — +//! search for repositories on the forge instance. +//! +//! Wraps `GET /repos/search` (Forgejo explore endpoint). Useful for agents +//! that need to find a repo by keyword rather than knowing the exact +//! `owner/repo` slug upfront. Not repo-scoped — the `-r` flag is ignored. +//! +//! Human output: one `owner/repo description` line per hit. `--json` +//! emits the full `SearchResults.data` array as raw JSON. + +use anyhow::Result; +use clap::Args as ClapArgs; +use forgejo_api::structs::RepoSearchQuery; + +use crate::client::Client; +use crate::verbs::print_json; + +#[derive(ClapArgs)] +pub struct Args { + /// Keyword to search for (matches repo name by default; combine with + /// `--include-desc` to also match description text). + #[arg(short = 'q', long)] + query: Option, + /// Restrict matches to repositories that have the keyword as a **topic** + /// tag rather than in the name. + #[arg(long)] + topic: bool, + /// Extend the keyword search to repository descriptions (in addition to + /// names, or topics when `--topic` is set). + #[arg(long = "include-desc")] + include_desc: bool, + /// Maximum number of results to return (default: 30). + #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))] + limit: u64, +} + +/// # Errors +/// +/// Propagates transport errors from the Forgejo search call or JSON +/// serialisation errors when emitting `--json` output. +pub fn run(client: &Client, args: Args) -> Result<()> { + let limit = u32::try_from(args.limit.min(u32::MAX as u64)).unwrap_or(u32::MAX); + let results = client + .api() + .repo_search(RepoSearchQuery { + q: args.query, + topic: args.topic.then_some(true), + include_desc: args.include_desc.then_some(true), + // All other filters left at defaults — callers who need them + // can drop to `--json` and post-filter. + uid: None, + priority_owner_id: None, + team_id: None, + starred_by: None, + private: None, + is_private: None, + template: None, + archived: None, + mode: None, + exclusive: None, + sort: None, + order: None, + }) + .page_size(limit) + .send()?; + + let repos = results.data.unwrap_or_default(); + if client.json_mode() { + return print_json(&serde_json::to_value(&repos)?); + } + if repos.is_empty() { + eprintln!("(no results)"); + return Ok(()); + } + for repo in &repos { + let full_name = repo.full_name.as_deref().unwrap_or("?/?"); + let desc = repo.description.as_deref().unwrap_or(""); + if desc.is_empty() { + println!("{full_name}"); + } else { + println!("{full_name} {desc}"); + } + } + Ok(()) +}