diff --git a/docs/HELP.md b/docs/HELP.md index a2d0df9..4e9392f 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -13,6 +13,10 @@ This document contains the help content for the `detail` command-line program. * [`detail bugs list`↴](#detail-bugs-list) * [`detail bugs show`↴](#detail-bugs-show) * [`detail bugs close`↴](#detail-bugs-close) +* [`detail rules`↴](#detail-rules) +* [`detail rules create`↴](#detail-rules-create) +* [`detail rules list`↴](#detail-rules-list) +* [`detail rules show`↴](#detail-rules-show) * [`detail satisfying-sort`↴](#detail-satisfying-sort) * [`detail repos`↴](#detail-repos) * [`detail repos list`↴](#detail-repos-list) @@ -36,6 +40,7 @@ Common workflow: * `auth` — Manage login credentials * `bugs` — List, show, and close bugs +* `rules` — Create and inspect rules * `satisfying-sort` — Run a fun animation. Humans only * `repos` — Manage repos tracked with Detail * `skill` — Install the detail-bugs skill @@ -171,6 +176,71 @@ Close a bug as resolved or dismissed +## `detail rules` + +Create and inspect rules + +**Usage:** `detail rules ` + +###### **Subcommands:** + +* `create` — Start an async rule creation job for a repository +* `list` — List rule creation requests for a repository +* `show` — Show the details or status of a rule creation request + + + +## `detail rules create` + +Start an async rule creation job for a repository + +**Usage:** `detail rules create [OPTIONS] ` + +###### **Arguments:** + +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name + +###### **Options:** + +* `--description ` — Description of the rule to create +* `--bug-id ` — Bug ID to use as context (repeatable) +* `--commit-sha ` — Commit SHA to examine for patterns (repeatable) + + + +## `detail rules list` + +List rule creation requests for a repository + +**Usage:** `detail rules list [OPTIONS] ` + +###### **Arguments:** + +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name + +###### **Options:** + +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + + + + +## `detail rules show` + +Show the details or status of a rule creation request + +**Usage:** `detail rules show ` + +###### **Arguments:** + +* `` — Rule creation request ID (rcr_...) + + + ## `detail satisfying-sort` Run a fun animation. Humans only diff --git a/openapi.json b/openapi.json index ad79cca..7cf1c44 100644 --- a/openapi.json +++ b/openapi.json @@ -234,6 +234,102 @@ "message", "statusCode" ] + }, + "RuleCreationRequestId": { + "type": "string", + "pattern": "^rcr_.*" + }, + "RuleStatus": { + "type": "string", + "enum": [ + "pending", + "complete", + "failed" + ] + }, + "CreateRuleInput": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "bugIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "commitShas": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "RuleListItem": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/RuleCreationRequestId" + }, + "status": { + "$ref": "#/components/schemas/RuleStatus" + }, + "input": { + "$ref": "#/components/schemas/CreateRuleInput" + }, + "ruleName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "integer" + } + }, + "required": [ + "id", + "status", + "input", + "createdAt" + ] + }, + "Rule": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/RuleCreationRequestId" + }, + "status": { + "$ref": "#/components/schemas/RuleStatus" + }, + "input": { + "$ref": "#/components/schemas/CreateRuleInput" + }, + "ruleName": { + "type": "string", + "nullable": true + }, + "ruleFiles": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "createdAt": { + "type": "integer" + }, + "completedAt": { + "type": "integer" + } + }, + "required": [ + "id", + "status", + "input", + "createdAt" + ] } } }, @@ -527,6 +623,197 @@ } } }, + "/public/v1/rules": { + "post": { + "operationId": "createRule", + "summary": "Create a rule", + "description": "Starts an async rule creation job for a repository. Returns a rule creation request ID to poll for the result.", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repo_id": { + "$ref": "#/components/schemas/RepoId" + }, + "input": { + "$ref": "#/components/schemas/CreateRuleInput" + } + }, + "required": [ + "repo_id", + "input" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ruleCreationRequestId": { + "$ref": "#/components/schemas/RuleCreationRequestId" + } + }, + "required": [ + "ruleCreationRequestId" + ] + } + } + } + }, + "4XX": { + "description": "Client error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "5XX": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "get": { + "operationId": "listRules", + "summary": "List rules", + "description": "Returns all rule creation requests for a repository, including pending and completed.", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "repo_id", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RepoId" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RuleListItem" + } + } + }, + "required": [ + "rules" + ] + } + } + } + }, + "4XX": { + "description": "Client error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "5XX": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/public/v1/rules/{rule_creation_request_id}": { + "get": { + "operationId": "getRule", + "summary": "Get a rule", + "description": "Returns full details for a rule, including generated files when complete.", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "rule_creation_request_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/RuleCreationRequestId" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rule" + } + } + } + }, + "4XX": { + "description": "Client error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "5XX": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/public/v1/repos": { "get": { "operationId": "listPublicRepos", diff --git a/src/api/client.rs b/src/api/client.rs index 2735a91..2e7087f 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -3,6 +3,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use reqwest::header::{HeaderMap, AUTHORIZATION}; +use super::generated::types::CreateRuleBody; use super::types::*; pub struct ApiClient { @@ -100,4 +101,36 @@ impl ApiClient { .map(|r| r.into_inner()) .map_err(|e| anyhow::anyhow!("API error: {}", e)) } + + pub async fn create_rule( + &self, + repo_id: &RepoId, + input: CreateRuleInput, + ) -> Result { + let body = CreateRuleBody { + repo_id: repo_id.clone(), + input, + }; + self.inner + .create_rule(&body) + .await + .map(|r| r.into_inner()) + .map_err(|e| anyhow::anyhow!("API error: {}", e)) + } + + pub async fn list_rules(&self, repo_id: &RepoId) -> Result { + self.inner + .list_rules(repo_id) + .await + .map(|r| r.into_inner()) + .map_err(|e| anyhow::anyhow!("API error: {}", e)) + } + + pub async fn get_rule(&self, rule_id: &RuleCreationRequestId) -> Result { + self.inner + .get_rule(rule_id) + .await + .map(|r| r.into_inner()) + .map_err(|e| anyhow::anyhow!("API error: {}", e)) + } } diff --git a/src/api/types.rs b/src/api/types.rs index 581b72e..750f811 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -6,13 +6,16 @@ use crate::utils::format_date; // Re-export generated types as the public API for this crate. pub use super::generated::types::{ Bug, BugDismissalReason, BugId, BugReview, BugReviewId, BugReviewState, - CreatePublicBugReviewBody, IntroducedIn, Org, OrgId, Repo, RepoId, + CreatePublicBugReviewBody, CreateRuleInput, IntroducedIn, Org, OrgId, Repo, RepoId, Rule, + RuleCreationRequestId, RuleListItem, RuleStatus, }; // Friendlier aliases for the generated response-wrapper names. pub type UserInfo = super::generated::types::GetPublicUserResponse; pub type BugsResponse = super::generated::types::ListPublicBugsResponse; pub type ReposResponse = super::generated::types::ListPublicReposResponse; +pub type CreateRuleResponse = super::generated::types::CreateRuleResponse; +pub type ListRulesResponse = super::generated::types::ListRulesResponse; // ── Display helpers ────────────────────────────────────────────────── // progenitor already implements Display for the generated enums, so we @@ -35,6 +38,14 @@ pub fn dismissal_reason_label(r: &BugDismissalReason) -> &'static str { } } +pub fn rule_status_label(s: &RuleStatus) -> &'static str { + match s { + RuleStatus::Pending => "Pending", + RuleStatus::Complete => "Complete", + RuleStatus::Failed => "Failed", + } +} + // ── clap::ValueEnum ────────────────────────────────────────────────── impl clap::ValueEnum for BugReviewState { diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index 6ec1f85..3958691 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -322,7 +322,7 @@ fn match_repo_by_name(name: &str, repos: &[Repo]) -> Result { } /// Resolve owner/repo or repo name to repo ID -async fn resolve_repo_id(client: &ApiClient, repo_identifier: &str) -> Result { +pub(crate) async fn resolve_repo_id(client: &ApiClient, repo_identifier: &str) -> Result { let repos = fetch_all_repos(client).await?; resolve_repo_id_from_repos(&repos, repo_identifier) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0082c73..ccef3d9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod bugs; pub mod repos; +pub mod rules; pub mod satisfying_sort; pub mod skill; pub mod update; diff --git a/src/commands/rules.rs b/src/commands/rules.rs new file mode 100644 index 0000000..e9afbad --- /dev/null +++ b/src/commands/rules.rs @@ -0,0 +1,280 @@ +use anyhow::{bail, Context, Result}; +use clap::Subcommand; +use console::{style, Term}; +use std::path::PathBuf; + +use crate::api::types::{ + rule_status_label, CreateRuleInput, RuleCreationRequestId, RuleListItem, +}; +use crate::commands::bugs::resolve_repo_id; +use crate::output::{output_list, SectionRenderer}; +use crate::utils::{format_date, format_datetime}; + +#[derive(Subcommand)] +pub enum RuleCommands { + /// Start an async rule creation job for a repository + Create { + /// Repository by owner/repo (e.g., usedetail/cli) or repo name + repo: String, + + /// Description of the rule to create + #[arg(long)] + description: Option, + + /// Bug ID to use as context (repeatable) + #[arg(long = "bug-id")] + bug_ids: Vec, + + /// Commit SHA to examine for patterns (repeatable) + #[arg(long = "commit-sha")] + commit_shas: Vec, + }, + + /// List rule creation requests for a repository + List { + /// Repository by owner/repo (e.g., usedetail/cli) or repo name + repo: String, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, + }, + + /// Show the details or status of a rule creation request + Show { + /// Rule creation request ID (rcr_...) + request_id: String, + }, + + /// Persist a completed rule's files to .agents/skills// + Persist { + /// Rule creation request ID (rcr_...) + request_id: String, + }, +} + +fn validate_create_input( + description: &Option, + bug_ids: &[String], + commit_shas: &[String], +) -> Result<()> { + if description.is_none() && bug_ids.is_empty() && commit_shas.is_empty() { + bail!("At least one of --description, --bug-id, or --commit-sha is required."); + } + Ok(()) +} + +pub async fn handle(command: &RuleCommands, cli: &crate::Cli) -> Result<()> { + let client = cli.create_client()?; + + match command { + RuleCommands::Create { + repo, + description, + bug_ids, + commit_shas, + } => { + validate_create_input(description, bug_ids, commit_shas)?; + + let repo_id = resolve_repo_id(&client, repo) + .await + .context("Failed to resolve repository identifier")?; + + let input = CreateRuleInput { + description: description.clone(), + bug_ids: bug_ids.clone(), + commit_shas: commit_shas.clone(), + }; + + let response = client + .create_rule(&repo_id, input) + .await + .context("Failed to start rule creation")?; + + Term::stdout().write_line(&format!( + "{} Rule creation started.", + style("✓").green(), + ))?; + Term::stdout().write_line(&format!( + " ID: {}", + style(response.rule_creation_request_id.to_string()).bold(), + ))?; + Term::stdout() + .write_line(" Use 'detail rules show ' to check progress.")?; + Ok(()) + } + + RuleCommands::List { repo, format } => { + let repo_id = resolve_repo_id(&client, repo) + .await + .context("Failed to resolve repository identifier")?; + + let response = client + .list_rules(&repo_id) + .await + .context("Failed to list rules")?; + + let total = response.rules.len(); + let limit = u32::try_from(total).unwrap_or(u32::MAX).max(1); + output_list(&response.rules, total, 1, limit, format) + } + + RuleCommands::Show { request_id } => { + let rule_id: RuleCreationRequestId = request_id + .as_str() + .try_into() + .context("Invalid rule request ID format (expected rcr_...)")?; + + let rule = client + .get_rule(&rule_id) + .await + .context("Failed to fetch rule")?; + + let mut pairs: Vec<(&str, String)> = vec![ + ("ID", rule.id.to_string()), + ("Status", rule_status_label(&rule.status).to_string()), + ("Created", format_datetime(rule.created_at)), + ]; + + if let Some(completed_at) = rule.completed_at { + pairs.push(("Completed", format_datetime(completed_at))); + } + if let Some(name) = &rule.rule_name { + pairs.push(("Rule Name", name.clone())); + } + if let Some(desc) = &rule.input.description { + pairs.push(("Description", desc.clone())); + } + if !rule.input.bug_ids.is_empty() { + pairs.push(("Bug IDs", rule.input.bug_ids.join(", "))); + } + if !rule.input.commit_shas.is_empty() { + pairs.push(("Commit SHAs", rule.input.commit_shas.join(", "))); + } + + let mut renderer = SectionRenderer::new().key_value("", &pairs); + + if let Some(files) = &rule.rule_files { + let mut sorted: Vec<(&String, &String)> = files + .iter() + // TODO: Remove this later, this is a hack + .filter(|(path, _)| !path.ends_with("files_to_check.json")) + .collect(); + sorted.sort_by_key(|(path, _)| path.as_str()); + for (path, content) in sorted { + renderer = renderer.markdown(path, content); + } + } + + renderer.print() + } + + RuleCommands::Persist { request_id } => { + let rule_id: RuleCreationRequestId = request_id + .as_str() + .try_into() + .context("Invalid rule request ID format (expected rcr_...)")?; + + let rule = client + .get_rule(&rule_id) + .await + .context("Failed to fetch rule")?; + + let files = rule + .rule_files + .as_ref() + .filter(|f| !f.is_empty()) + .context("Rule has no files to persist (may still be pending)")?; + + let rule_name = rule + .rule_name + .as_deref() + .unwrap_or(request_id.as_str()); + + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let out_dir: PathBuf = cwd.join(".agents").join("skills").join(rule_name); + + std::fs::create_dir_all(&out_dir).with_context(|| { + format!("Failed to create directory {}", out_dir.display()) + })?; + + let mut written: Vec = Vec::new(); + for (path, content) in files { + if path.ends_with("files_to_check.json") { + continue; + } + let dest = out_dir.join(path); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create directory {}", parent.display()) + })?; + } + std::fs::write(&dest, content) + .with_context(|| format!("Failed to write {}", dest.display()))?; + written.push(path.clone()); + } + + Term::stdout().write_line(&format!( + "{} Persisted {} file(s) to {}", + style("✓").green(), + written.len(), + style(out_dir.display()).bold(), + ))?; + for path in &written { + Term::stdout().write_line(&format!(" {}", path))?; + } + Ok(()) + } + } +} + +impl crate::output::Formattable for RuleListItem { + fn to_card(&self) -> (String, Vec<(&'static str, String)>) { + let header = self + .rule_name + .clone() + .unwrap_or_else(|| self.id.to_string()); + let pairs = vec![ + ("ID", self.id.to_string()), + ("Status", rule_status_label(&self.status).to_string()), + ("Created", format_date(self.created_at)), + ]; + (header, pairs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_create_rejects_all_empty() { + let err = validate_create_input(&None, &[], &[]).unwrap_err(); + assert!(err.to_string().contains("At least one of")); + } + + #[test] + fn validate_create_accepts_description_only() { + assert!(validate_create_input(&Some("no SQL".into()), &[], &[]).is_ok()); + } + + #[test] + fn validate_create_accepts_bug_ids_only() { + assert!(validate_create_input(&None, &["bug_1".into()], &[]).is_ok()); + } + + #[test] + fn validate_create_accepts_commit_shas_only() { + assert!(validate_create_input(&None, &[], &["abc1234".into()]).is_ok()); + } + + #[test] + fn validate_create_accepts_all_fields() { + assert!(validate_create_input( + &Some("desc".into()), + &["bug_1".into()], + &["abc".into()], + ) + .is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs index fbb68f4..706dc61 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,6 +61,13 @@ impl Cli { .. } ), + Commands::Rules { command } => matches!( + command, + commands::rules::RuleCommands::List { + format: OutputFormat::Json, + .. + } + ), _ => false, } } @@ -85,6 +92,7 @@ impl Cli { match &self.command { Commands::Auth { command } => commands::auth::handle(command, &self).await, Commands::Bugs { command } => commands::bugs::handle(command, &self).await, + Commands::Rules { command } => commands::rules::handle(command, &self).await, Commands::SatisfyingSort => commands::satisfying_sort::handle().await, Commands::Repos { command } => commands::repos::handle(command, &self).await, Commands::Skill => commands::skill::handle(), @@ -117,6 +125,12 @@ enum Commands { command: commands::bugs::BugCommands, }, + /// Create and inspect rules + Rules { + #[command(subcommand)] + command: commands::rules::RuleCommands, + }, + /// Run a fun animation. Humans only. #[command(name = "satisfying-sort")] SatisfyingSort,